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