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