gabriel / musehub public
test_musehub_user_activity.py python
457 lines 14.6 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """Tests for GET /musehub/users/{username}/activity — .
2
3 Covers:
4 - 404 for unknown username
5 - Empty feed for a user with no events
6 - Events from public repos appear in the feed
7 - Events from private repos are hidden from unauthenticated callers
8 - Events from private repos are visible to the repo owner
9 - type filter works correctly (push, pull_request, issue, release)
10 - type filter for types with no DB equivalent (star, fork) returns empty feed
11 - Cursor pagination (before_id) returns the next page correctly
12 - limit parameter is respected (default 30, max 100)
13 - next_cursor is None when there are no more events
14 - type_filter is echoed back in the response
15 """
16 from __future__ import annotations
17
18 import pytest
19 from httpx import AsyncClient
20 from sqlalchemy.ext.asyncio import AsyncSession
21
22 from musehub.db.musehub_models import MusehubProfile, MusehubRepo
23 from musehub.services import musehub_events
24
25
26 # ---------------------------------------------------------------------------
27 # Helpers
28 # ---------------------------------------------------------------------------
29
30
31 async def _create_profile(
32 db: AsyncSession,
33 user_id: str = "uid-act-001",
34 username: str = "actuser",
35 ) -> MusehubProfile:
36 profile = MusehubProfile(user_id=user_id, username=username, pinned_repo_ids=[])
37 db.add(profile)
38 await db.flush()
39 return profile
40
41
42 async def _create_repo(
43 db: AsyncSession,
44 owner: str = "actuser",
45 slug: str = "jazzrepo",
46 owner_user_id: str = "uid-act-001",
47 visibility: str = "public",
48 ) -> MusehubRepo:
49 repo = MusehubRepo(
50 name=slug,
51 owner=owner,
52 slug=slug,
53 visibility=visibility,
54 owner_user_id=owner_user_id,
55 )
56 db.add(repo)
57 await db.flush()
58 return repo
59
60
61 # ---------------------------------------------------------------------------
62 # Service-layer tests
63 # ---------------------------------------------------------------------------
64
65
66 @pytest.mark.anyio
67 async def test_list_user_activity_empty_for_no_events(db_session: AsyncSession) -> None:
68 """list_user_activity returns an empty feed when the user has no events."""
69 await _create_profile(db_session)
70 result = await musehub_events.list_user_activity(db_session, "actuser")
71 assert result.events == []
72 assert result.next_cursor is None
73
74
75 @pytest.mark.anyio
76 async def test_list_user_activity_public_repo_events_visible(
77 db_session: AsyncSession,
78 ) -> None:
79 """Events from public repos appear in the activity feed."""
80 await _create_profile(db_session)
81 repo = await _create_repo(db_session, visibility="public")
82 await musehub_events.record_event(
83 db_session,
84 repo_id=repo.repo_id,
85 event_type="commit_pushed",
86 actor="actuser",
87 description="Add groove baseline",
88 metadata={"sha": "abc123"},
89 )
90 await db_session.commit()
91
92 result = await musehub_events.list_user_activity(db_session, "actuser")
93 assert len(result.events) == 1
94 ev = result.events[0]
95 assert ev.actor == "actuser"
96 assert ev.type == "push"
97 assert ev.repo == "actuser/jazzrepo"
98 assert ev.payload["sha"] == "abc123"
99
100
101 @pytest.mark.anyio
102 async def test_list_user_activity_private_repo_hidden_from_unauthenticated(
103 db_session: AsyncSession,
104 ) -> None:
105 """Events from private repos are not returned when caller_user_id is None."""
106 await _create_profile(db_session)
107 repo = await _create_repo(db_session, slug="privrepo", visibility="private")
108 await musehub_events.record_event(
109 db_session,
110 repo_id=repo.repo_id,
111 event_type="commit_pushed",
112 actor="actuser",
113 description="Secret work",
114 )
115 await db_session.commit()
116
117 result = await musehub_events.list_user_activity(
118 db_session, "actuser", caller_user_id=None
119 )
120 assert result.events == []
121
122
123 @pytest.mark.anyio
124 async def test_list_user_activity_private_repo_visible_to_owner(
125 db_session: AsyncSession,
126 ) -> None:
127 """Events from private repos are visible when caller_user_id matches the repo owner."""
128 await _create_profile(db_session)
129 repo = await _create_repo(
130 db_session, slug="privrepo2", visibility="private", owner_user_id="uid-act-001"
131 )
132 await musehub_events.record_event(
133 db_session,
134 repo_id=repo.repo_id,
135 event_type="commit_pushed",
136 actor="actuser",
137 description="Owner can see private work",
138 )
139 await db_session.commit()
140
141 result = await musehub_events.list_user_activity(
142 db_session, "actuser", caller_user_id="uid-act-001"
143 )
144 assert len(result.events) == 1
145
146
147 @pytest.mark.anyio
148 async def test_list_user_activity_type_filter_push(db_session: AsyncSession) -> None:
149 """type_filter='push' returns only push-type events (commit_pushed)."""
150 await _create_profile(db_session)
151 repo = await _create_repo(db_session)
152 await musehub_events.record_event(
153 db_session, repo_id=repo.repo_id, event_type="commit_pushed",
154 actor="actuser", description="pushed",
155 )
156 await musehub_events.record_event(
157 db_session, repo_id=repo.repo_id, event_type="issue_opened",
158 actor="actuser", description="issue",
159 )
160 await db_session.commit()
161
162 result = await musehub_events.list_user_activity(
163 db_session, "actuser", type_filter="push"
164 )
165 assert len(result.events) == 1
166 assert result.events[0].type == "push"
167 assert result.type_filter == "push"
168
169
170 @pytest.mark.anyio
171 async def test_list_user_activity_type_filter_pull_request(
172 db_session: AsyncSession,
173 ) -> None:
174 """type_filter='pull_request' returns pr_opened, pr_merged, pr_closed events."""
175 await _create_profile(db_session)
176 repo = await _create_repo(db_session)
177 await musehub_events.record_event(
178 db_session, repo_id=repo.repo_id, event_type="pr_opened",
179 actor="actuser", description="opened PR",
180 )
181 await musehub_events.record_event(
182 db_session, repo_id=repo.repo_id, event_type="pr_merged",
183 actor="actuser", description="merged PR",
184 )
185 await musehub_events.record_event(
186 db_session, repo_id=repo.repo_id, event_type="commit_pushed",
187 actor="actuser", description="push noise",
188 )
189 await db_session.commit()
190
191 result = await musehub_events.list_user_activity(
192 db_session, "actuser", type_filter="pull_request"
193 )
194 assert len(result.events) == 2
195 for ev in result.events:
196 assert ev.type == "pull_request"
197
198
199 @pytest.mark.anyio
200 async def test_list_user_activity_type_filter_star_returns_empty(
201 db_session: AsyncSession,
202 ) -> None:
203 """type_filter='star' returns empty feed (no star events in DB yet)."""
204 await _create_profile(db_session)
205 repo = await _create_repo(db_session)
206 await musehub_events.record_event(
207 db_session, repo_id=repo.repo_id, event_type="commit_pushed",
208 actor="actuser", description="push",
209 )
210 await db_session.commit()
211
212 result = await musehub_events.list_user_activity(
213 db_session, "actuser", type_filter="star"
214 )
215 assert result.events == []
216 assert result.type_filter == "star"
217
218
219 @pytest.mark.anyio
220 async def test_list_user_activity_limit_respected(db_session: AsyncSession) -> None:
221 """limit parameter caps the number of returned events."""
222 await _create_profile(db_session)
223 repo = await _create_repo(db_session)
224 for i in range(5):
225 await musehub_events.record_event(
226 db_session, repo_id=repo.repo_id, event_type="commit_pushed",
227 actor="actuser", description=f"commit {i}",
228 )
229 await db_session.commit()
230
231 result = await musehub_events.list_user_activity(
232 db_session, "actuser", limit=3
233 )
234 assert len(result.events) == 3
235
236
237 @pytest.mark.anyio
238 async def test_list_user_activity_cursor_pagination(db_session: AsyncSession) -> None:
239 """before_id cursor returns the next page of events disjoint from the first page."""
240 await _create_profile(db_session)
241 repo = await _create_repo(db_session)
242 for i in range(5):
243 await musehub_events.record_event(
244 db_session, repo_id=repo.repo_id, event_type="commit_pushed",
245 actor="actuser", description=f"commit {i}",
246 )
247 await db_session.commit()
248
249 page1 = await musehub_events.list_user_activity(db_session, "actuser", limit=3)
250 assert len(page1.events) == 3
251 assert page1.next_cursor is not None
252
253 page2 = await musehub_events.list_user_activity(
254 db_session, "actuser", limit=3, before_id=page1.next_cursor
255 )
256 assert len(page2.events) == 2
257
258 ids1 = {e.id for e in page1.events}
259 ids2 = {e.id for e in page2.events}
260 assert ids1.isdisjoint(ids2)
261
262
263 @pytest.mark.anyio
264 async def test_list_user_activity_next_cursor_none_on_last_page(
265 db_session: AsyncSession,
266 ) -> None:
267 """next_cursor is None when all events fit within the limit."""
268 await _create_profile(db_session)
269 repo = await _create_repo(db_session)
270 for i in range(2):
271 await musehub_events.record_event(
272 db_session, repo_id=repo.repo_id, event_type="commit_pushed",
273 actor="actuser", description=f"commit {i}",
274 )
275 await db_session.commit()
276
277 result = await musehub_events.list_user_activity(
278 db_session, "actuser", limit=10
279 )
280 assert len(result.events) == 2
281 assert result.next_cursor is None
282
283
284 @pytest.mark.anyio
285 async def test_list_user_activity_events_newest_first(db_session: AsyncSession) -> None:
286 """User activity events are ordered newest-first."""
287 await _create_profile(db_session)
288 repo = await _create_repo(db_session)
289 for i in range(3):
290 await musehub_events.record_event(
291 db_session, repo_id=repo.repo_id, event_type="commit_pushed",
292 actor="actuser", description=f"commit {i}",
293 )
294 await db_session.commit()
295
296 result = await musehub_events.list_user_activity(db_session, "actuser")
297 timestamps = [e.created_at for e in result.events]
298 assert timestamps == sorted(timestamps, reverse=True)
299
300
301 # ---------------------------------------------------------------------------
302 # HTTP API tests
303 # ---------------------------------------------------------------------------
304
305
306 @pytest.mark.anyio
307 async def test_get_user_activity_404_unknown_username(client: AsyncClient) -> None:
308 """GET /musehub/users/{username}/activity returns 404 for unknown username."""
309 response = await client.get("/api/v1/users/nobody-xyz/activity")
310 assert response.status_code == 404
311
312
313 @pytest.mark.anyio
314 async def test_get_user_activity_empty_feed(
315 client: AsyncClient,
316 auth_headers: dict[str, str],
317 ) -> None:
318 """GET activity returns empty feed for a user with no events."""
319 await client.post(
320 "/api/v1/users",
321 json={"username": "acttest1"},
322 headers=auth_headers,
323 )
324 response = await client.get("/api/v1/users/acttest1/activity")
325 assert response.status_code == 200
326 body = response.json()
327 assert body["events"] == []
328 assert body["nextCursor"] is None
329
330
331 @pytest.mark.anyio
332 async def test_get_user_activity_returns_public_events(
333 client: AsyncClient,
334 db_session: AsyncSession,
335 auth_headers: dict[str, str],
336 ) -> None:
337 """GET activity returns events from public repos."""
338 await client.post(
339 "/api/v1/users",
340 json={"username": "acttest2"},
341 headers=auth_headers,
342 )
343 repo_resp = await client.post(
344 "/api/v1/repos",
345 json={"name": "act-repo", "owner": "acttest2", "visibility": "public"},
346 headers=auth_headers,
347 )
348 assert repo_resp.status_code == 201
349 repo_id = repo_resp.json()["repoId"]
350
351 await musehub_events.record_event(
352 db_session, repo_id=repo_id, event_type="commit_pushed",
353 actor="acttest2", description="Hello world",
354 )
355 await db_session.commit()
356
357 response = await client.get("/api/v1/users/acttest2/activity")
358 assert response.status_code == 200
359 body = response.json()
360 assert len(body["events"]) == 1
361 ev = body["events"][0]
362 assert ev["type"] == "push"
363 assert ev["actor"] == "acttest2"
364 assert "acttest2/" in ev["repo"]
365
366
367 @pytest.mark.anyio
368 async def test_get_user_activity_type_filter_via_api(
369 client: AsyncClient,
370 db_session: AsyncSession,
371 auth_headers: dict[str, str],
372 ) -> None:
373 """?type=issue filter returns only issue-type events."""
374 await client.post(
375 "/api/v1/users",
376 json={"username": "acttest3"},
377 headers=auth_headers,
378 )
379 repo_resp = await client.post(
380 "/api/v1/repos",
381 json={"name": "act-repo3", "owner": "acttest3", "visibility": "public"},
382 headers=auth_headers,
383 )
384 assert repo_resp.status_code == 201
385 repo_id = repo_resp.json()["repoId"]
386
387 await musehub_events.record_event(
388 db_session, repo_id=repo_id, event_type="commit_pushed",
389 actor="acttest3", description="push",
390 )
391 await musehub_events.record_event(
392 db_session, repo_id=repo_id, event_type="issue_opened",
393 actor="acttest3", description="opened issue",
394 )
395 await db_session.commit()
396
397 response = await client.get(
398 "/api/v1/users/acttest3/activity?type=issue"
399 )
400 assert response.status_code == 200
401 body = response.json()
402 assert len(body["events"]) == 1
403 assert body["events"][0]["type"] == "issue"
404 assert body["typeFilter"] == "issue"
405
406
407 @pytest.mark.anyio
408 async def test_get_user_activity_invalid_type_returns_422(
409 client: AsyncClient,
410 auth_headers: dict[str, str],
411 ) -> None:
412 """?type=invalid returns 422 (query param validation)."""
413 await client.post(
414 "/api/v1/users",
415 json={"username": "acttest4"},
416 headers=auth_headers,
417 )
418 response = await client.get(
419 "/api/v1/users/acttest4/activity?type=invalid"
420 )
421 assert response.status_code == 422
422
423
424 @pytest.mark.anyio
425 async def test_get_user_activity_limit_param(
426 client: AsyncClient,
427 db_session: AsyncSession,
428 auth_headers: dict[str, str],
429 ) -> None:
430 """?limit=2 returns at most 2 events."""
431 await client.post(
432 "/api/v1/users",
433 json={"username": "acttest5"},
434 headers=auth_headers,
435 )
436 repo_resp = await client.post(
437 "/api/v1/repos",
438 json={"name": "act-repo5", "owner": "acttest5", "visibility": "public"},
439 headers=auth_headers,
440 )
441 assert repo_resp.status_code == 201
442 repo_id = repo_resp.json()["repoId"]
443
444 for i in range(5):
445 await musehub_events.record_event(
446 db_session, repo_id=repo_id, event_type="commit_pushed",
447 actor="acttest5", description=f"commit {i}",
448 )
449 await db_session.commit()
450
451 response = await client.get(
452 "/api/v1/users/acttest5/activity?limit=2"
453 )
454 assert response.status_code == 200
455 body = response.json()
456 assert len(body["events"]) == 2
457 assert body["nextCursor"] is not None