gabriel / musehub public
test_musehub_topics.py python
376 lines 13.4 KB
Raw
sha256:0997d6250ae6476362f6fe2025af7789f46d03df3e9f34356d5e8ee79b201923 fix(issues): use issue number as pagination cursor, not cre… Sonnet 4.6 patch 9 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.core.genesis import compute_identity_id, compute_repo_id
32 from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubRepo
33 from musehub.types.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 | None = None,
49 ) -> str:
50 """Seed a repo and return its repo_id."""
51 from datetime import datetime, timezone
52 slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:64].strip("-") or "repo"
53 full_slug = f"{slug}-{visibility[:3]}"
54 created_at = datetime.now(tz=timezone.utc)
55 effective_owner_id = owner_user_id or compute_identity_id(owner.encode())
56 repo = MusehubRepo(
57 repo_id=compute_repo_id(effective_owner_id, full_slug, "code", created_at.isoformat()),
58 name=name,
59 owner=owner,
60 slug=full_slug,
61 visibility=visibility,
62 owner_user_id=effective_owner_id,
63 description="",
64 tags=tags or [],
65 created_at=created_at,
66 updated_at=created_at,
67 )
68 db_session.add(repo)
69 await db_session.commit()
70 await db_session.refresh(repo)
71 return str(repo.repo_id)
72
73
74 async def _add_commit(
75 db_session: AsyncSession,
76 repo_id: str,
77 *,
78 sha: str,
79 timestamp: str,
80 ) -> None:
81 from datetime import datetime, timezone
82 commit = MusehubCommit(
83 commit_id=sha,
84 branch="main",
85 author="tester",
86 message="test commit",
87 timestamp=datetime.fromisoformat(timestamp).replace(tzinfo=timezone.utc),
88 parent_ids=[],
89 )
90 db_session.add(commit)
91 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id=sha))
92 await db_session.commit()
93
94
95 # ---------------------------------------------------------------------------
96 # GET /api/topics
97 # ---------------------------------------------------------------------------
98
99
100 async def test_list_topics_empty(client: AsyncClient) -> None:
101 """No public repos → topics list is empty."""
102 response = await client.get("/api/topics")
103 assert response.status_code == 200
104 assert response.json() == {"topics": []}
105
106
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/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 async def test_list_topics_excludes_private_repos(
125 client: AsyncClient, db_session: AsyncSession
126 ) -> None:
127 """Private repo tags do not contribute to topic counts."""
128 await _make_repo(db_session, name="pub-jazz", tags=["jazz"], visibility="public")
129 await _make_repo(db_session, name="priv-jazz", tags=["jazz", "secret-tag"], visibility="private")
130
131 response = await client.get("/api/topics")
132 assert response.status_code == 200
133
134 topics = {t["name"]: t["repo_count"] for t in response.json()["topics"]}
135 assert topics.get("jazz") == 1 # only the public repo
136 assert "secret-tag" not in topics
137
138
139 async def test_list_topics_sorted_by_count_desc(
140 client: AsyncClient, db_session: AsyncSession
141 ) -> None:
142 """Topics are sorted by repo_count descending — most popular first."""
143 await _make_repo(db_session, name="r1", tags=["baroque"])
144 await _make_repo(db_session, name="r2", tags=["jazz", "baroque"])
145 await _make_repo(db_session, name="r3", tags=["jazz", "baroque"])
146
147 response = await client.get("/api/topics")
148 assert response.status_code == 200
149
150 topics = response.json()["topics"]
151 assert topics[0]["name"] == "baroque" # 3 repos
152 assert topics[1]["name"] == "jazz" # 2 repos
153
154
155 # ---------------------------------------------------------------------------
156 # GET /api/topics/{tag}/repos
157 # ---------------------------------------------------------------------------
158
159
160 async def test_repos_by_topic_empty(client: AsyncClient) -> None:
161 """Unknown/unused tag → empty repos list, not 404."""
162 response = await client.get("/api/topics/nonexistent-tag/repos")
163 assert response.status_code == 200
164 body = response.json()
165 assert body["repos"] == []
166 assert body["total"] == 0
167 assert body["tag"] == "nonexistent-tag"
168
169
170 async def test_repos_by_topic_returns_tagged_repos(
171 client: AsyncClient, db_session: AsyncSession
172 ) -> None:
173 """Only repos with the exact tag are returned."""
174 await _make_repo(db_session, name="jazz-repo", tags=["jazz", "piano"])
175 await _make_repo(db_session, name="piano-only-repo", tags=["piano"])
176 await _make_repo(db_session, name="unrelated-repo", tags=["ambient"])
177
178 response = await client.get("/api/topics/jazz/repos")
179 assert response.status_code == 200
180 body = response.json()
181 assert body["total"] == 1
182 assert body["tag"] == "jazz"
183 assert body["repos"][0]["name"] == "jazz-repo"
184
185
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 async def test_repos_by_topic_sort_by_updated(
199 client: AsyncClient, db_session: AsyncSession
200 ) -> None:
201 """sort=updated returns most-recently-committed repo first."""
202 id_old = await _make_repo(db_session, name="old-commits", tags=["ambient"])
203 id_new = await _make_repo(db_session, name="new-commits", tags=["ambient"])
204
205 await _add_commit(db_session, id_old, sha="sha-old", timestamp="2023-01-01T00:00:00")
206 await _add_commit(db_session, id_new, sha="sha-new", timestamp="2024-06-01T00:00:00")
207
208 response = await client.get("/api/topics/ambient/repos?sort=updated")
209 assert response.status_code == 200
210 names = [r["name"] for r in response.json()["repos"]]
211 assert names.index("new-commits") < names.index("old-commits")
212
213
214 async def test_repos_by_topic_invalid_sort(client: AsyncClient, db_session: AsyncSession) -> None:
215 """Invalid sort parameter returns 422."""
216 await _make_repo(db_session, name="any-repo", tags=["jazz"])
217 response = await client.get("/api/topics/jazz/repos?sort=invalid")
218 assert response.status_code == 422
219
220
221 async def test_repos_by_topic_pagination(
222 client: AsyncClient, db_session: AsyncSession
223 ) -> None:
224 """Cursor pagination: second page returns a different set of repos."""
225 for i in range(5):
226 await _make_repo(db_session, name=f"cinematic-{i}", tags=["cinematic"])
227
228 page1_resp = await client.get("/api/topics/cinematic/repos?limit=2")
229 assert page1_resp.status_code == 200
230 page1 = page1_resp.json()
231 assert page1["total"] == 5
232 assert page1["nextCursor"] is not None
233
234 next_cursor = page1["nextCursor"]
235 page2_resp = await client.get(f"/api/topics/cinematic/repos?limit=2&cursor={next_cursor}")
236 assert page2_resp.status_code == 200
237 page2 = page2_resp.json()
238
239 ids1 = {r["repoId"] for r in page1["repos"]}
240 ids2 = {r["repoId"] for r in page2["repos"]}
241 assert ids1.isdisjoint(ids2)
242
243
244 # ---------------------------------------------------------------------------
245 # POST /api/repos/{repo_id}/topics
246 # ---------------------------------------------------------------------------
247
248
249 async def test_set_topics_requires_auth(
250 client: AsyncClient, db_session: AsyncSession
251 ) -> None:
252 """POST without a MSign Authorization header returns 401."""
253 repo_id = await _make_repo(db_session, name="auth-test")
254 response = await client.post(
255 f"/api/repos/{repo_id}/topics",
256 json={"topics": ["jazz"]},
257 )
258 assert response.status_code == 401
259
260
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 async def test_set_topics_replaces_list(
275 client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
276 ) -> None:
277 """Posting a new list replaces the existing tags entirely."""
278 repo_id = await _make_repo(
279 db_session,
280 name="replace-me",
281 tags=["old-tag"],
282 owner_user_id=compute_identity_id(b"testuser"),
283 )
284 response = await client.post(
285 f"/api/repos/{repo_id}/topics",
286 json={"topics": ["jazz", "piano"]},
287 headers=auth_headers,
288 )
289 assert response.status_code == 200
290 body = response.json()
291 assert body["repo_id"] == repo_id
292 assert body["topics"] == ["jazz", "piano"]
293
294
295 async def test_set_topics_deduplicates(
296 client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
297 ) -> None:
298 """Duplicate topic slugs in the request are silently collapsed."""
299 repo_id = await _make_repo(
300 db_session,
301 name="dedup-test",
302 owner_user_id=compute_identity_id(b"testuser"),
303 )
304 response = await client.post(
305 f"/api/repos/{repo_id}/topics",
306 json={"topics": ["jazz", "jazz", "piano", "jazz"]},
307 headers=auth_headers,
308 )
309 assert response.status_code == 200
310 assert response.json()["topics"] == ["jazz", "piano"]
311
312
313 async def test_set_topics_invalid_slug(
314 client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
315 ) -> None:
316 """Topic slugs with invalid characters return 422."""
317 repo_id = await _make_repo(
318 db_session,
319 name="slug-test",
320 owner_user_id=compute_identity_id(b"testuser"),
321 )
322 response = await client.post(
323 f"/api/repos/{repo_id}/topics",
324 json={"topics": ["Valid-slug", "BAD SLUG!", "ok-slug"]},
325 headers=auth_headers,
326 )
327 assert response.status_code == 422
328
329
330 async def test_set_topics_too_many(
331 client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
332 ) -> None:
333 """Submitting more than 20 topics returns 422."""
334 repo_id = await _make_repo(
335 db_session,
336 name="too-many",
337 owner_user_id=compute_identity_id(b"testuser"),
338 )
339 many_topics = [f"topic-{i}" for i in range(21)]
340 response = await client.post(
341 f"/api/repos/{repo_id}/topics",
342 json={"topics": many_topics},
343 headers=auth_headers,
344 )
345 assert response.status_code == 422
346
347
348 async def test_set_topics_clears_list(
349 client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
350 ) -> None:
351 """Sending an empty list removes all topics."""
352 repo_id = await _make_repo(
353 db_session,
354 name="clear-me",
355 tags=["jazz", "piano"],
356 owner_user_id=compute_identity_id(b"testuser"),
357 )
358 response = await client.post(
359 f"/api/repos/{repo_id}/topics",
360 json={"topics": []},
361 headers=auth_headers,
362 )
363 assert response.status_code == 200
364 assert response.json()["topics"] == []
365
366
367 async def test_set_topics_repo_not_found(
368 client: AsyncClient, auth_headers: StrDict
369 ) -> None:
370 """Unknown repo_id returns 404."""
371 response = await client.post(
372 "/api/repos/nonexistent-repo-id/topics",
373 json={"topics": ["jazz"]},
374 headers=auth_headers,
375 )
376 assert response.status_code == 404
File History 1 commit
sha256:0997d6250ae6476362f6fe2025af7789f46d03df3e9f34356d5e8ee79b201923 fix(issues): use issue number as pagination cursor, not cre… Sonnet 4.6 patch 9 days ago