gabriel / musehub public
test_musehub_ui_topics.py python
468 lines 16.3 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d 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 a JWT
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 a JWT
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_stars — ?sort=stars returns repos sorted by star count
26 - test_topic_detail_sort_updated — ?sort=updated accepted without error
27 - test_topic_detail_invalid_sort_fallback — invalid sort silently falls back to stars
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, MusehubStar
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 async def _star_repo(db_session: AsyncSession, repo_id: str, user_id: str) -> None:
72 """Add a star to a repo."""
73 star = MusehubStar(repo_id=repo_id, user_id=user_id)
74 db_session.add(star)
75 await db_session.commit()
76
77
78 _INDEX_URL = "/topics"
79 _DETAIL_URL = "/topics/jazz"
80
81
82 # ---------------------------------------------------------------------------
83 # Topics Index — HTML rendering
84 # ---------------------------------------------------------------------------
85
86
87 @pytest.mark.anyio
88 async def test_topics_index_renders_200(
89 client: AsyncClient,
90 db_session: AsyncSession,
91 ) -> None:
92 """GET /topics must return 200 HTML."""
93 response = await client.get(_INDEX_URL)
94 assert response.status_code == 200
95 assert "text/html" in response.headers["content-type"]
96
97
98 @pytest.mark.anyio
99 async def test_topics_index_no_auth_required(
100 client: AsyncClient,
101 db_session: AsyncSession,
102 ) -> None:
103 """Topics index must be accessible without an Authorization header."""
104 response = await client.get(_INDEX_URL)
105 assert response.status_code == 200
106
107
108 @pytest.mark.anyio
109 async def test_topics_index_html_has_page_mode(
110 client: AsyncClient,
111 db_session: AsyncSession,
112 ) -> None:
113 """HTML response must embed PAGE_MODE = 'index' as a JS variable."""
114 response = await client.get(_INDEX_URL)
115 assert response.status_code == 200
116 body = response.text
117 assert "PAGE_MODE" in body
118 assert '"index"' in body
119
120
121 @pytest.mark.anyio
122 async def test_topics_index_html_has_curated_groups(
123 client: AsyncClient,
124 db_session: AsyncSession,
125 ) -> None:
126 """HTML shell must reference the topics data endpoint for client-side loading."""
127 response = await client.get(_INDEX_URL)
128 assert response.status_code == 200
129 body = response.text
130 # The JS references the UI endpoint for data loading
131 assert "/topics" in body
132
133
134 # ---------------------------------------------------------------------------
135 # Topics Index — JSON content negotiation
136 # ---------------------------------------------------------------------------
137
138
139 @pytest.mark.anyio
140 async def test_topics_index_json_content_negotiation(
141 client: AsyncClient,
142 db_session: AsyncSession,
143 ) -> None:
144 """Accept: application/json must return a JSON response."""
145 response = await client.get(_INDEX_URL, headers={"Accept": "application/json"})
146 assert response.status_code == 200
147 assert "application/json" in response.headers["content-type"]
148
149
150 @pytest.mark.anyio
151 async def test_topics_index_format_param(
152 client: AsyncClient,
153 db_session: AsyncSession,
154 ) -> None:
155 """?format=json must return JSON without an Accept header."""
156 response = await client.get(_INDEX_URL + "?format=json")
157 assert response.status_code == 200
158 assert "application/json" in response.headers["content-type"]
159
160
161 @pytest.mark.anyio
162 async def test_topics_index_json_schema(
163 client: AsyncClient,
164 db_session: AsyncSession,
165 ) -> None:
166 """JSON response must contain allTopics, curatedGroups, and total keys."""
167 await _make_repo(db_session, tags=["jazz"])
168 response = await client.get(_INDEX_URL + "?format=json")
169 assert response.status_code == 200
170 data = response.json()
171 assert "allTopics" in data
172 assert "curatedGroups" in data
173 assert "total" in data
174 assert isinstance(data["allTopics"], list)
175 assert isinstance(data["curatedGroups"], list)
176 assert isinstance(data["total"], int)
177
178
179 @pytest.mark.anyio
180 async def test_topics_index_empty_state(
181 client: AsyncClient,
182 db_session: AsyncSession,
183 ) -> None:
184 """With no repos, allTopics must be empty and total must be 0."""
185 response = await client.get(_INDEX_URL + "?format=json")
186 assert response.status_code == 200
187 data = response.json()
188 assert data["allTopics"] == []
189 assert data["total"] == 0
190
191
192 @pytest.mark.anyio
193 async def test_topics_index_counts_public_only(
194 client: AsyncClient,
195 db_session: AsyncSession,
196 ) -> None:
197 """Private repo tags must not appear in the topics index."""
198 await _make_repo(db_session, tags=["secret-tag"], visibility="private")
199 response = await client.get(_INDEX_URL + "?format=json")
200 assert response.status_code == 200
201 data = response.json()
202 topic_names = [t["name"] for t in data["allTopics"]]
203 assert "secret-tag" not in topic_names
204
205
206 @pytest.mark.anyio
207 async def test_topics_index_sorted_by_popularity(
208 client: AsyncClient,
209 db_session: AsyncSession,
210 ) -> None:
211 """Topics must be sorted by repo_count descending (most popular first)."""
212 await _make_repo(db_session, name="r1", slug="r1", tags=["jazz"])
213 await _make_repo(db_session, name="r2", slug="r2", tags=["jazz", "blues"])
214 await _make_repo(db_session, name="r3", slug="r3", tags=["blues"])
215 response = await client.get(_INDEX_URL + "?format=json")
216 assert response.status_code == 200
217 data = response.json()
218 topics = data["allTopics"]
219 # jazz: 2 repos, blues: 2 repos (tie) — both before any single-repo topic
220 counts = [t["repo_count"] for t in topics]
221 assert counts == sorted(counts, reverse=True), "Topics not sorted by repo_count desc"
222
223
224 @pytest.mark.anyio
225 async def test_topics_index_curated_groups_populated(
226 client: AsyncClient,
227 db_session: AsyncSession,
228 ) -> None:
229 """Curated groups must include Genres, Instruments, and Eras with topic items."""
230 await _make_repo(db_session, tags=["jazz", "piano"])
231 response = await client.get(_INDEX_URL + "?format=json")
232 assert response.status_code == 200
233 data = response.json()
234 group_labels = [g["label"] for g in data["curatedGroups"]]
235 assert "Genres" in group_labels
236 assert "Instruments" in group_labels
237 assert "Eras" in group_labels
238
239 # Jazz and piano should appear in their curated groups with repoCount > 0
240 genres_group = next(g for g in data["curatedGroups"] if g["label"] == "Genres")
241 jazz_item = next((t for t in genres_group["topics"] if t["name"] == "jazz"), None)
242 assert jazz_item is not None
243 assert jazz_item["repo_count"] == 1
244
245 instruments_group = next(g for g in data["curatedGroups"] if g["label"] == "Instruments")
246 piano_item = next((t for t in instruments_group["topics"] if t["name"] == "piano"), None)
247 assert piano_item is not None
248 assert piano_item["repo_count"] == 1
249
250
251 # ---------------------------------------------------------------------------
252 # Topic Detail — HTML rendering
253 # ---------------------------------------------------------------------------
254
255
256 @pytest.mark.anyio
257 async def test_topic_detail_renders_200(
258 client: AsyncClient,
259 db_session: AsyncSession,
260 ) -> None:
261 """GET /topics/{tag} must return 200 HTML."""
262 response = await client.get(_DETAIL_URL)
263 assert response.status_code == 200
264 assert "text/html" in response.headers["content-type"]
265
266
267 @pytest.mark.anyio
268 async def test_topic_detail_no_auth_required(
269 client: AsyncClient,
270 db_session: AsyncSession,
271 ) -> None:
272 """Topic detail page must be accessible without a JWT."""
273 response = await client.get(_DETAIL_URL)
274 assert response.status_code == 200
275
276
277 @pytest.mark.anyio
278 async def test_topic_detail_tag_injected_in_js(
279 client: AsyncClient,
280 db_session: AsyncSession,
281 ) -> None:
282 """Tag slug must be passed as the TOPIC_TAG JS variable."""
283 response = await client.get(_DETAIL_URL)
284 assert response.status_code == 200
285 body = response.text
286 assert "TOPIC_TAG" in body
287 assert '"jazz"' in body
288
289
290 @pytest.mark.anyio
291 async def test_topic_detail_sort_injected_in_js(
292 client: AsyncClient,
293 db_session: AsyncSession,
294 ) -> None:
295 """Sort param must be passed as the TOPIC_SORT JS variable."""
296 response = await client.get(_DETAIL_URL + "?sort=updated")
297 assert response.status_code == 200
298 body = response.text
299 assert "TOPIC_SORT" in body
300 assert '"updated"' in body
301
302
303 @pytest.mark.anyio
304 async def test_topic_detail_html_has_breadcrumb(
305 client: AsyncClient,
306 db_session: AsyncSession,
307 ) -> None:
308 """HTML breadcrumb must reference Topics index and the current tag slug."""
309 response = await client.get(_DETAIL_URL)
310 assert response.status_code == 200
311 body = response.text
312 assert "Topics" in body
313 assert "jazz" in body
314
315
316 @pytest.mark.anyio
317 async def test_topic_detail_html_references_api(
318 client: AsyncClient,
319 db_session: AsyncSession,
320 ) -> None:
321 """HTML must reference the topics UI data endpoint for client-side data fetching."""
322 response = await client.get(_DETAIL_URL)
323 assert response.status_code == 200
324 body = response.text
325 assert "/topics" in body
326
327
328 # ---------------------------------------------------------------------------
329 # Topic Detail — JSON content negotiation
330 # ---------------------------------------------------------------------------
331
332
333 @pytest.mark.anyio
334 async def test_topic_detail_json_response(
335 client: AsyncClient,
336 db_session: AsyncSession,
337 ) -> None:
338 """Accept: application/json must return a JSON response."""
339 response = await client.get(_DETAIL_URL, headers={"Accept": "application/json"})
340 assert response.status_code == 200
341 assert "application/json" in response.headers["content-type"]
342
343
344 @pytest.mark.anyio
345 async def test_topic_detail_json_schema(
346 client: AsyncClient,
347 db_session: AsyncSession,
348 ) -> None:
349 """JSON response must contain tag, repos, total, page, pageSize keys."""
350 response = await client.get(_DETAIL_URL + "?format=json")
351 assert response.status_code == 200
352 data = response.json()
353 assert "tag" in data
354 assert "repos" in data
355 assert "total" in data
356 assert "page" in data
357 assert "page_size" in data
358 assert isinstance(data["repos"], list)
359 assert isinstance(data["total"], int)
360 assert data["tag"] == "jazz"
361
362
363 @pytest.mark.anyio
364 async def test_topic_detail_empty_topic(
365 client: AsyncClient,
366 db_session: AsyncSession,
367 ) -> None:
368 """Unknown tag must return 200 with an empty repos list (not 404)."""
369 response = await client.get("/topics/no-such-genre?format=json")
370 assert response.status_code == 200
371 data = response.json()
372 assert data["repos"] == []
373 assert data["total"] == 0
374
375
376 @pytest.mark.anyio
377 async def test_topic_detail_filters_by_tag(
378 client: AsyncClient,
379 db_session: AsyncSession,
380 ) -> None:
381 """Only repos that carry the requested tag must appear in the response."""
382 await _make_repo(db_session, name="jazz-repo", slug="jazz-repo", tags=["jazz", "piano"])
383 await _make_repo(db_session, name="blues-repo", slug="blues-repo", tags=["blues"])
384 response = await client.get(_DETAIL_URL + "?format=json")
385 assert response.status_code == 200
386 data = response.json()
387 assert data["total"] == 1
388 assert len(data["repos"]) == 1
389 assert data["repos"][0]["slug"] == "jazz-repo"
390
391
392 @pytest.mark.anyio
393 async def test_topic_detail_private_excluded(
394 client: AsyncClient,
395 db_session: AsyncSession,
396 ) -> None:
397 """Private repos tagged with the topic must not appear in results."""
398 await _make_repo(
399 db_session, name="private-jazz", slug="private-jazz",
400 tags=["jazz"], visibility="private"
401 )
402 response = await client.get(_DETAIL_URL + "?format=json")
403 assert response.status_code == 200
404 data = response.json()
405 assert data["total"] == 0
406 assert data["repos"] == []
407
408
409 @pytest.mark.anyio
410 async def test_topic_detail_sort_stars(
411 client: AsyncClient,
412 db_session: AsyncSession,
413 ) -> None:
414 """?sort=stars must return repos without error; default sort is stars."""
415 await _make_repo(db_session, name="jazz-a", slug="jazz-a", tags=["jazz"])
416 await _make_repo(db_session, name="jazz-b", slug="jazz-b", tags=["jazz"])
417 response = await client.get(_DETAIL_URL + "?sort=stars&format=json")
418 assert response.status_code == 200
419 data = response.json()
420 assert data["total"] == 2
421
422
423 @pytest.mark.anyio
424 async def test_topic_detail_sort_updated(
425 client: AsyncClient,
426 db_session: AsyncSession,
427 ) -> None:
428 """?sort=updated must be accepted and return repos without error."""
429 await _make_repo(db_session, name="jazz-recent", slug="jazz-recent", tags=["jazz"])
430 response = await client.get(_DETAIL_URL + "?sort=updated&format=json")
431 assert response.status_code == 200
432 data = response.json()
433 assert data["total"] == 1
434
435
436 @pytest.mark.anyio
437 async def test_topic_detail_invalid_sort_fallback(
438 client: AsyncClient,
439 db_session: AsyncSession,
440 ) -> None:
441 """An invalid ?sort value must silently fall back to stars — no 422."""
442 await _make_repo(db_session, name="jazz-x", slug="jazz-x", tags=["jazz"])
443 response = await client.get(_DETAIL_URL + "?sort=bogus&format=json")
444 assert response.status_code == 200
445 data = response.json()
446 assert data["total"] == 1
447
448
449 @pytest.mark.anyio
450 async def test_topic_detail_pagination(
451 client: AsyncClient,
452 db_session: AsyncSession,
453 ) -> None:
454 """?page=2 with a small page_size must return the second page of results."""
455 for i in range(3):
456 await _make_repo(
457 db_session,
458 name=f"jazz-{i}",
459 slug=f"jazz-{i}",
460 tags=["jazz"],
461 )
462 response = await client.get(_DETAIL_URL + "?page=2&page_size=2&format=json")
463 assert response.status_code == 200
464 data = response.json()
465 assert data["total"] == 3
466 assert data["page"] == 2
467 # Page 2 with page_size=2 from 3 total → 1 result
468 assert len(data["repos"]) == 1