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