gabriel / musehub public
test_musehub_topics.py python
405 lines 14.3 KB
cd448303 Initial extraction of MuseHub from maestro monorepo. Gabriel Cardona <gabriel@tellurstori.com> 7d ago
1 """Tests for the Muse Hub topics/tag browse API endpoints.
2
3 Covers acceptance criteria:
4 - test_list_topics_empty — no public repos → empty topics list
5 - test_list_topics_aggregates_counts — counts reflect public repos only
6 - test_list_topics_excludes_private_repos — private repo tags are not counted
7 - test_list_topics_sorted_by_count_desc — most popular topic appears first
8 - test_repos_by_topic_empty — unknown tag → empty list (not 404)
9 - test_repos_by_topic_returns_tagged_repos — only repos with exact tag returned
10 - test_repos_by_topic_excludes_private — private repos are hidden
11 - test_repos_by_topic_sort_by_stars — stars sort returns most-starred first
12 - test_repos_by_topic_sort_by_updated — updated sort returns most-recently-committed first
13 - test_repos_by_topic_invalid_sort — invalid sort param returns 422
14 - test_repos_by_topic_pagination — page 2 returns different repos
15 - test_set_topics_requires_auth — POST without JWT returns 401
16 - test_set_topics_owner_only — non-owner gets 403
17 - test_set_topics_replaces_list — new list replaces old list entirely
18 - test_set_topics_deduplicates — duplicate slugs are collapsed
19 - test_set_topics_invalid_slug — bad slug characters return 422
20 - test_set_topics_too_many — more than 20 topics returns 422
21 - test_set_topics_clears_list — empty body clears all topics
22 - test_set_topics_repo_not_found — unknown repo_id returns 404
23 """
24 from __future__ import annotations
25
26 import re
27
28 import pytest
29 from httpx import AsyncClient
30 from sqlalchemy.ext.asyncio import AsyncSession
31
32 from musehub.db.musehub_models import MusehubCommit, MusehubRepo, MusehubStar
33
34
35 # ---------------------------------------------------------------------------
36 # Helpers
37 # ---------------------------------------------------------------------------
38
39
40 async def _make_repo(
41 db_session: AsyncSession,
42 *,
43 name: str,
44 visibility: str = "public",
45 tags: list[str] | None = None,
46 owner: str = "testuser",
47 owner_user_id: str = "test-owner",
48 ) -> str:
49 """Seed a repo and return its repo_id."""
50 slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:64].strip("-") or "repo"
51 repo = MusehubRepo(
52 name=name,
53 owner=owner,
54 slug=f"{slug}-{visibility[:3]}",
55 visibility=visibility,
56 owner_user_id=owner_user_id,
57 description="",
58 tags=tags or [],
59 )
60 db_session.add(repo)
61 await db_session.commit()
62 await db_session.refresh(repo)
63 return str(repo.repo_id)
64
65
66 async def _add_star(db_session: AsyncSession, repo_id: str, user_id: str) -> None:
67 star = MusehubStar(repo_id=repo_id, user_id=user_id)
68 db_session.add(star)
69 await db_session.commit()
70
71
72 async def _add_commit(
73 db_session: AsyncSession,
74 repo_id: str,
75 *,
76 sha: str,
77 timestamp: str,
78 ) -> None:
79 from datetime import datetime, timezone
80 commit = MusehubCommit(
81 commit_id=sha,
82 repo_id=repo_id,
83 branch="main",
84 author="tester",
85 message="test commit",
86 timestamp=datetime.fromisoformat(timestamp).replace(tzinfo=timezone.utc),
87 parent_ids=[],
88 )
89 db_session.add(commit)
90 await db_session.commit()
91
92
93 # ---------------------------------------------------------------------------
94 # GET /api/v1/musehub/topics
95 # ---------------------------------------------------------------------------
96
97
98 @pytest.mark.anyio
99 async def test_list_topics_empty(client: AsyncClient) -> None:
100 """No public repos → topics list is empty."""
101 response = await client.get("/api/v1/musehub/topics")
102 assert response.status_code == 200
103 assert response.json() == {"topics": []}
104
105
106 @pytest.mark.anyio
107 async def test_list_topics_aggregates_counts(
108 client: AsyncClient, db_session: AsyncSession
109 ) -> None:
110 """Topics are aggregated across all public repos with correct counts."""
111 await _make_repo(db_session, name="repo-a", tags=["jazz", "piano"])
112 await _make_repo(db_session, name="repo-b", tags=["jazz", "ambient"])
113 await _make_repo(db_session, name="repo-c", tags=["ambient"])
114
115 response = await client.get("/api/v1/musehub/topics")
116 assert response.status_code == 200
117
118 topics = {t["name"]: t["repo_count"] for t in response.json()["topics"]}
119 assert topics["jazz"] == 2
120 assert topics["ambient"] == 2
121 assert topics["piano"] == 1
122
123
124 @pytest.mark.anyio
125 async def test_list_topics_excludes_private_repos(
126 client: AsyncClient, db_session: AsyncSession
127 ) -> None:
128 """Private repo tags do not contribute to topic counts."""
129 await _make_repo(db_session, name="pub-jazz", tags=["jazz"], visibility="public")
130 await _make_repo(db_session, name="priv-jazz", tags=["jazz", "secret-tag"], visibility="private")
131
132 response = await client.get("/api/v1/musehub/topics")
133 assert response.status_code == 200
134
135 topics = {t["name"]: t["repo_count"] for t in response.json()["topics"]}
136 assert topics.get("jazz") == 1 # only the public repo
137 assert "secret-tag" not in topics
138
139
140 @pytest.mark.anyio
141 async def test_list_topics_sorted_by_count_desc(
142 client: AsyncClient, db_session: AsyncSession
143 ) -> None:
144 """Topics are sorted by repo_count descending — most popular first."""
145 await _make_repo(db_session, name="r1", tags=["baroque"])
146 await _make_repo(db_session, name="r2", tags=["jazz", "baroque"])
147 await _make_repo(db_session, name="r3", tags=["jazz", "baroque"])
148
149 response = await client.get("/api/v1/musehub/topics")
150 assert response.status_code == 200
151
152 topics = response.json()["topics"]
153 assert topics[0]["name"] == "baroque" # 3 repos
154 assert topics[1]["name"] == "jazz" # 2 repos
155
156
157 # ---------------------------------------------------------------------------
158 # GET /api/v1/musehub/topics/{tag}/repos
159 # ---------------------------------------------------------------------------
160
161
162 @pytest.mark.anyio
163 async def test_repos_by_topic_empty(client: AsyncClient) -> None:
164 """Unknown/unused tag → empty repos list, not 404."""
165 response = await client.get("/api/v1/musehub/topics/nonexistent-tag/repos")
166 assert response.status_code == 200
167 body = response.json()
168 assert body["repos"] == []
169 assert body["total"] == 0
170 assert body["tag"] == "nonexistent-tag"
171
172
173 @pytest.mark.anyio
174 async def test_repos_by_topic_returns_tagged_repos(
175 client: AsyncClient, db_session: AsyncSession
176 ) -> None:
177 """Only repos with the exact tag are returned."""
178 await _make_repo(db_session, name="jazz-repo", tags=["jazz", "piano"])
179 await _make_repo(db_session, name="piano-only-repo", tags=["piano"])
180 await _make_repo(db_session, name="unrelated-repo", tags=["ambient"])
181
182 response = await client.get("/api/v1/musehub/topics/jazz/repos")
183 assert response.status_code == 200
184 body = response.json()
185 assert body["total"] == 1
186 assert body["tag"] == "jazz"
187 assert body["repos"][0]["name"] == "jazz-repo"
188
189
190 @pytest.mark.anyio
191 async def test_repos_by_topic_excludes_private(
192 client: AsyncClient, db_session: AsyncSession
193 ) -> None:
194 """Private repos are not exposed even when they carry the tag."""
195 await _make_repo(db_session, name="pub", tags=["classical"], visibility="public")
196 await _make_repo(db_session, name="priv", tags=["classical"], visibility="private")
197
198 response = await client.get("/api/v1/musehub/topics/classical/repos")
199 assert response.status_code == 200
200 assert response.json()["total"] == 1 # only the public repo
201
202
203 @pytest.mark.anyio
204 async def test_repos_by_topic_sort_by_stars(
205 client: AsyncClient, db_session: AsyncSession
206 ) -> None:
207 """sort=stars returns most-starred repo first."""
208 id_low = await _make_repo(db_session, name="low-star", tags=["edm"])
209 id_high = await _make_repo(db_session, name="high-star", tags=["edm"])
210
211 await _add_star(db_session, id_high, "user1")
212 await _add_star(db_session, id_high, "user2")
213 await _add_star(db_session, id_low, "user3")
214
215 response = await client.get("/api/v1/musehub/topics/edm/repos?sort=stars")
216 assert response.status_code == 200
217 names = [r["name"] for r in response.json()["repos"]]
218 assert names.index("high-star") < names.index("low-star")
219
220
221 @pytest.mark.anyio
222 async def test_repos_by_topic_sort_by_updated(
223 client: AsyncClient, db_session: AsyncSession
224 ) -> None:
225 """sort=updated returns most-recently-committed repo first."""
226 id_old = await _make_repo(db_session, name="old-commits", tags=["ambient"])
227 id_new = await _make_repo(db_session, name="new-commits", tags=["ambient"])
228
229 await _add_commit(db_session, id_old, sha="sha-old", timestamp="2023-01-01T00:00:00")
230 await _add_commit(db_session, id_new, sha="sha-new", timestamp="2024-06-01T00:00:00")
231
232 response = await client.get("/api/v1/musehub/topics/ambient/repos?sort=updated")
233 assert response.status_code == 200
234 names = [r["name"] for r in response.json()["repos"]]
235 assert names.index("new-commits") < names.index("old-commits")
236
237
238 @pytest.mark.anyio
239 async def test_repos_by_topic_invalid_sort(client: AsyncClient, db_session: AsyncSession) -> None:
240 """Invalid sort parameter returns 422."""
241 await _make_repo(db_session, name="any-repo", tags=["jazz"])
242 response = await client.get("/api/v1/musehub/topics/jazz/repos?sort=invalid")
243 assert response.status_code == 422
244
245
246 @pytest.mark.anyio
247 async def test_repos_by_topic_pagination(
248 client: AsyncClient, db_session: AsyncSession
249 ) -> None:
250 """Pagination works: page 2 returns a different set of repos."""
251 for i in range(5):
252 await _make_repo(db_session, name=f"cinematic-{i}", tags=["cinematic"])
253
254 page1 = await client.get("/api/v1/musehub/topics/cinematic/repos?page=1&page_size=2")
255 page2 = await client.get("/api/v1/musehub/topics/cinematic/repos?page=2&page_size=2")
256
257 assert page1.status_code == 200
258 assert page2.status_code == 200
259 ids1 = {r["repoId"] for r in page1.json()["repos"]}
260 ids2 = {r["repoId"] for r in page2.json()["repos"]}
261 assert ids1.isdisjoint(ids2)
262 assert page1.json()["total"] == 5
263
264
265 # ---------------------------------------------------------------------------
266 # POST /api/v1/musehub/repos/{repo_id}/topics
267 # ---------------------------------------------------------------------------
268
269
270 @pytest.mark.anyio
271 async def test_set_topics_requires_auth(
272 client: AsyncClient, db_session: AsyncSession
273 ) -> None:
274 """POST without a JWT returns 401."""
275 repo_id = await _make_repo(db_session, name="auth-test")
276 response = await client.post(
277 f"/api/v1/musehub/repos/{repo_id}/topics",
278 json={"topics": ["jazz"]},
279 )
280 assert response.status_code == 401
281
282
283 @pytest.mark.anyio
284 async def test_set_topics_owner_only(
285 client: AsyncClient, db_session: AsyncSession, auth_headers: dict[str, str]
286 ) -> None:
287 """A user who is not the repo owner receives 403."""
288 repo_id = await _make_repo(db_session, name="owned-elsewhere", owner_user_id="different-owner")
289 response = await client.post(
290 f"/api/v1/musehub/repos/{repo_id}/topics",
291 json={"topics": ["jazz"]},
292 headers=auth_headers,
293 )
294 assert response.status_code == 403
295
296
297 @pytest.mark.anyio
298 async def test_set_topics_replaces_list(
299 client: AsyncClient, db_session: AsyncSession, auth_headers: dict[str, str]
300 ) -> None:
301 """Posting a new list replaces the existing tags entirely."""
302 repo_id = await _make_repo(
303 db_session,
304 name="replace-me",
305 tags=["old-tag"],
306 owner_user_id="550e8400-e29b-41d4-a716-446655440000",
307 )
308 response = await client.post(
309 f"/api/v1/musehub/repos/{repo_id}/topics",
310 json={"topics": ["jazz", "piano"]},
311 headers=auth_headers,
312 )
313 assert response.status_code == 200
314 body = response.json()
315 assert body["repo_id"] == repo_id
316 assert body["topics"] == ["jazz", "piano"]
317
318
319 @pytest.mark.anyio
320 async def test_set_topics_deduplicates(
321 client: AsyncClient, db_session: AsyncSession, auth_headers: dict[str, str]
322 ) -> None:
323 """Duplicate topic slugs in the request are silently collapsed."""
324 repo_id = await _make_repo(
325 db_session,
326 name="dedup-test",
327 owner_user_id="550e8400-e29b-41d4-a716-446655440000",
328 )
329 response = await client.post(
330 f"/api/v1/musehub/repos/{repo_id}/topics",
331 json={"topics": ["jazz", "jazz", "piano", "jazz"]},
332 headers=auth_headers,
333 )
334 assert response.status_code == 200
335 assert response.json()["topics"] == ["jazz", "piano"]
336
337
338 @pytest.mark.anyio
339 async def test_set_topics_invalid_slug(
340 client: AsyncClient, db_session: AsyncSession, auth_headers: dict[str, str]
341 ) -> None:
342 """Topic slugs with invalid characters return 422."""
343 repo_id = await _make_repo(
344 db_session,
345 name="slug-test",
346 owner_user_id="550e8400-e29b-41d4-a716-446655440000",
347 )
348 response = await client.post(
349 f"/api/v1/musehub/repos/{repo_id}/topics",
350 json={"topics": ["Valid-slug", "BAD SLUG!", "ok-slug"]},
351 headers=auth_headers,
352 )
353 assert response.status_code == 422
354
355
356 @pytest.mark.anyio
357 async def test_set_topics_too_many(
358 client: AsyncClient, db_session: AsyncSession, auth_headers: dict[str, str]
359 ) -> None:
360 """Submitting more than 20 topics returns 422."""
361 repo_id = await _make_repo(
362 db_session,
363 name="too-many",
364 owner_user_id="550e8400-e29b-41d4-a716-446655440000",
365 )
366 many_topics = [f"topic-{i}" for i in range(21)]
367 response = await client.post(
368 f"/api/v1/musehub/repos/{repo_id}/topics",
369 json={"topics": many_topics},
370 headers=auth_headers,
371 )
372 assert response.status_code == 422
373
374
375 @pytest.mark.anyio
376 async def test_set_topics_clears_list(
377 client: AsyncClient, db_session: AsyncSession, auth_headers: dict[str, str]
378 ) -> None:
379 """Sending an empty list removes all topics."""
380 repo_id = await _make_repo(
381 db_session,
382 name="clear-me",
383 tags=["jazz", "piano"],
384 owner_user_id="550e8400-e29b-41d4-a716-446655440000",
385 )
386 response = await client.post(
387 f"/api/v1/musehub/repos/{repo_id}/topics",
388 json={"topics": []},
389 headers=auth_headers,
390 )
391 assert response.status_code == 200
392 assert response.json()["topics"] == []
393
394
395 @pytest.mark.anyio
396 async def test_set_topics_repo_not_found(
397 client: AsyncClient, auth_headers: dict[str, str]
398 ) -> None:
399 """Unknown repo_id returns 404."""
400 response = await client.post(
401 "/api/v1/musehub/repos/nonexistent-repo-id/topics",
402 json={"topics": ["jazz"]},
403 headers=auth_headers,
404 )
405 assert response.status_code == 404