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