gabriel / musehub public
test_musehub_social.py python
1266 lines 42.3 KB
8e92773a chore: consolidate to single migration, remove AI ORM layer Gabriel Cardona <gabriel@tellurstori.com> 6d ago
1 """Tests for Muse Hub social layer endpoints (social.py).
2
3 Covers all endpoint groups introduced in PR #318:
4 - Comments: list, create, soft-delete (owner guard, auth guard)
5 - Reactions: list counts, toggle idempotency (add → remove on second call)
6 - Follow/Unfollow: follower count, follow (auth, self-follow 400), unfollow
7 - Watch/Unwatch: watch count, watch (auth), unwatch
8 - Notifications: list inbox, mark single read, mark-all-read
9 - Forks: list forks, fork (public only, auth)
10 - Analytics: summary (private 401 guard), daily views, view-event debounce
11 - Feed: auth-gated activity feed
12
13 Key invariants asserted:
14 - toggle_reaction is idempotent: calling twice removes the reaction
15 - follow_user returns 400 when following yourself
16 - fork_repo returns 403 on private repos
17 - View-event debounce: duplicate (repo, fingerprint, date) → no error, no extra row
18 - Private repo analytics return 401 without auth
19 - All write endpoints return 401 without a token
20 """
21 from __future__ import annotations
22
23 import uuid
24 from datetime import datetime, timezone
25
26 import pytest
27 from httpx import AsyncClient
28 from sqlalchemy.ext.asyncio import AsyncSession
29
30 from musehub.db.musehub_models import (
31 MusehubFork,
32 MusehubNotification,
33 MusehubRepo,
34 MusehubStar,
35 MusehubViewEvent,
36 MusehubWatch,
37 )
38
39 # ---------------------------------------------------------------------------
40 # Helpers
41 # ---------------------------------------------------------------------------
42
43 _TEST_USER_ID = "550e8400-e29b-41d4-a716-446655440000"
44
45
46 async def _make_repo(
47 client: AsyncClient,
48 auth_headers: dict[str, str],
49 *,
50 name: str = "test-repo",
51 visibility: str = "public",
52 ) -> str:
53 """Create a repo via API and return its repo_id."""
54 resp = await client.post(
55 "/api/v1/musehub/repos",
56 json={"name": name, "owner": "testuser", "visibility": visibility},
57 headers=auth_headers,
58 )
59 assert resp.status_code == 201, resp.text
60 repo_id: str = resp.json()["repoId"]
61 return repo_id
62
63
64 async def _make_private_repo(
65 db_session: AsyncSession,
66 *,
67 name: str = "private-repo",
68 owner: str = "testuser",
69 ) -> str:
70 """Insert a private repo directly and return its repo_id."""
71 repo = MusehubRepo(
72 name=name,
73 owner=owner,
74 slug=name,
75 visibility="private",
76 owner_user_id=_TEST_USER_ID,
77 )
78 db_session.add(repo)
79 await db_session.commit()
80 await db_session.refresh(repo)
81 repo_id: str = str(repo.repo_id)
82 return repo_id
83
84
85 # ---------------------------------------------------------------------------
86 # Comments — GET
87 # ---------------------------------------------------------------------------
88
89
90 @pytest.mark.anyio
91 async def test_list_comments_empty_on_new_repo(
92 client: AsyncClient,
93 auth_headers: dict[str, str],
94 ) -> None:
95 """GET /repos/{id}/comments returns empty list when no comments exist."""
96 repo_id = await _make_repo(client, auth_headers, name="comment-empty")
97 resp = await client.get(
98 f"/api/v1/musehub/repos/{repo_id}/comments",
99 params={"target_type": "repo", "target_id": repo_id},
100 headers=auth_headers,
101 )
102 assert resp.status_code == 200
103 assert resp.json() == []
104
105
106 @pytest.mark.anyio
107 async def test_list_comments_on_private_repo_requires_auth(
108 client: AsyncClient,
109 db_session: AsyncSession,
110 ) -> None:
111 """GET /repos/{id}/comments returns 401 for private repo without auth."""
112 repo_id = await _make_private_repo(db_session, name="comment-private")
113 resp = await client.get(
114 f"/api/v1/musehub/repos/{repo_id}/comments",
115 params={"target_type": "repo", "target_id": repo_id},
116 )
117 assert resp.status_code == 401
118
119
120 @pytest.mark.anyio
121 async def test_list_comments_not_found_returns_404(
122 client: AsyncClient,
123 auth_headers: dict[str, str],
124 ) -> None:
125 """GET /repos/{id}/comments returns 404 for unknown repo."""
126 resp = await client.get(
127 "/api/v1/musehub/repos/no-such-repo/comments",
128 params={"target_type": "repo", "target_id": "anything"},
129 headers=auth_headers,
130 )
131 assert resp.status_code == 404
132
133
134 # ---------------------------------------------------------------------------
135 # Comments — POST
136 # ---------------------------------------------------------------------------
137
138
139 @pytest.mark.anyio
140 async def test_create_comment_returns_201(
141 client: AsyncClient,
142 auth_headers: dict[str, str],
143 ) -> None:
144 """POST /repos/{id}/comments creates a comment and returns 201 with required fields."""
145 repo_id = await _make_repo(client, auth_headers, name="comment-create")
146 resp = await client.post(
147 f"/api/v1/musehub/repos/{repo_id}/comments",
148 json={"target_type": "repo", "target_id": repo_id, "body": "Great track!"},
149 headers=auth_headers,
150 )
151 assert resp.status_code == 201
152 body = resp.json()
153 assert body["body"] == "Great track!"
154 assert body["author"] == _TEST_USER_ID
155 assert body["is_deleted"] is False
156 assert "comment_id" in body
157
158
159 @pytest.mark.anyio
160 async def test_create_comment_requires_auth(
161 client: AsyncClient,
162 auth_headers: dict[str, str],
163 ) -> None:
164 """POST /repos/{id}/comments returns 401 without Bearer token."""
165 repo_id = await _make_repo(client, auth_headers, name="comment-auth")
166 resp = await client.post(
167 f"/api/v1/musehub/repos/{repo_id}/comments",
168 json={"target_type": "repo", "target_id": repo_id, "body": "hi"},
169 )
170 assert resp.status_code == 401
171
172
173 @pytest.mark.anyio
174 async def test_created_comment_appears_in_list(
175 client: AsyncClient,
176 auth_headers: dict[str, str],
177 ) -> None:
178 """A posted comment is returned by the list endpoint."""
179 repo_id = await _make_repo(client, auth_headers, name="comment-roundtrip")
180 await client.post(
181 f"/api/v1/musehub/repos/{repo_id}/comments",
182 json={"target_type": "repo", "target_id": repo_id, "body": "Hello world"},
183 headers=auth_headers,
184 )
185 resp = await client.get(
186 f"/api/v1/musehub/repos/{repo_id}/comments",
187 params={"target_type": "repo", "target_id": repo_id},
188 headers=auth_headers,
189 )
190 assert resp.status_code == 200
191 comments = resp.json()
192 assert len(comments) == 1
193 assert comments[0]["body"] == "Hello world"
194
195
196 @pytest.mark.anyio
197 async def test_create_comment_with_release_target_type_returns_201(
198 client: AsyncClient,
199 auth_headers: dict[str, str],
200 ) -> None:
201 """POST /repos/{id}/comments accepts target_type='release' (added in PR #376)."""
202 repo_id = await _make_repo(client, auth_headers, name="comment-release")
203 release_id = "v1.0"
204 resp = await client.post(
205 f"/api/v1/musehub/repos/{repo_id}/comments",
206 json={"target_type": "release", "target_id": release_id, "body": "Amazing release!"},
207 headers=auth_headers,
208 )
209 assert resp.status_code == 201
210 body = resp.json()
211 assert body["body"] == "Amazing release!"
212 assert body["is_deleted"] is False
213 assert "comment_id" in body
214
215
216 # ---------------------------------------------------------------------------
217 # Comments — DELETE
218 # ---------------------------------------------------------------------------
219
220
221 @pytest.mark.anyio
222 async def test_delete_comment_soft_deletes(
223 client: AsyncClient,
224 auth_headers: dict[str, str],
225 ) -> None:
226 """DELETE /repos/{id}/comments/{cid} soft-deletes and returns 204."""
227 repo_id = await _make_repo(client, auth_headers, name="comment-delete")
228 create = await client.post(
229 f"/api/v1/musehub/repos/{repo_id}/comments",
230 json={"target_type": "repo", "target_id": repo_id, "body": "To be deleted"},
231 headers=auth_headers,
232 )
233 comment_id = create.json()["comment_id"]
234 resp = await client.delete(
235 f"/api/v1/musehub/repos/{repo_id}/comments/{comment_id}",
236 headers=auth_headers,
237 )
238 assert resp.status_code == 204
239
240
241 @pytest.mark.anyio
242 async def test_delete_comment_requires_auth(
243 client: AsyncClient,
244 auth_headers: dict[str, str],
245 ) -> None:
246 """DELETE /repos/{id}/comments/{cid} returns 401 without token."""
247 repo_id = await _make_repo(client, auth_headers, name="comment-del-auth")
248 create = await client.post(
249 f"/api/v1/musehub/repos/{repo_id}/comments",
250 json={"target_type": "repo", "target_id": repo_id, "body": "Keep this"},
251 headers=auth_headers,
252 )
253 comment_id = create.json()["comment_id"]
254 resp = await client.delete(
255 f"/api/v1/musehub/repos/{repo_id}/comments/{comment_id}",
256 )
257 assert resp.status_code == 401
258
259
260 @pytest.mark.anyio
261 async def test_delete_comment_forbidden_for_non_owner(
262 client: AsyncClient,
263 auth_headers: dict[str, str],
264 db_session: AsyncSession,
265 ) -> None:
266 """DELETE /repos/{id}/comments/{cid} returns 403 if caller is not the author."""
267 from musehub.auth.tokens import create_access_token
268 from musehub.db.models import User
269 from musehub.db.musehub_models import MusehubComment
270
271 # Create a second user
272 other_user = User(id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
273 db_session.add(other_user)
274 await db_session.commit()
275 other_token = create_access_token(user_id=other_user.id, expires_hours=1)
276 other_headers = {"Authorization": f"Bearer {other_token}", "Content-Type": "application/json"}
277
278 repo_id = await _make_repo(client, auth_headers, name="comment-forbid")
279
280 # Post a comment as the primary test user
281 create = await client.post(
282 f"/api/v1/musehub/repos/{repo_id}/comments",
283 json={"target_type": "repo", "target_id": repo_id, "body": "Mine"},
284 headers=auth_headers,
285 )
286 comment_id = create.json()["comment_id"]
287
288 # Try to delete as the other user
289 resp = await client.delete(
290 f"/api/v1/musehub/repos/{repo_id}/comments/{comment_id}",
291 headers=other_headers,
292 )
293 assert resp.status_code == 403
294
295
296 # ---------------------------------------------------------------------------
297 # Reactions — GET
298 # ---------------------------------------------------------------------------
299
300
301 @pytest.mark.anyio
302 async def test_list_reactions_empty_on_new_repo(
303 client: AsyncClient,
304 auth_headers: dict[str, str],
305 ) -> None:
306 """GET /repos/{id}/reactions returns empty list when no reactions exist."""
307 repo_id = await _make_repo(client, auth_headers, name="reaction-empty")
308 resp = await client.get(
309 f"/api/v1/musehub/repos/{repo_id}/reactions",
310 params={"target_type": "repo", "target_id": repo_id},
311 headers=auth_headers,
312 )
313 assert resp.status_code == 200
314 assert resp.json() == []
315
316
317 # ---------------------------------------------------------------------------
318 # Reactions — POST (toggle idempotency)
319 # ---------------------------------------------------------------------------
320
321
322 @pytest.mark.anyio
323 async def test_toggle_reaction_adds_on_first_call(
324 client: AsyncClient,
325 auth_headers: dict[str, str],
326 ) -> None:
327 """First POST /repos/{id}/reactions call adds the reaction (added=True)."""
328 repo_id = await _make_repo(client, auth_headers, name="reaction-add")
329 resp = await client.post(
330 f"/api/v1/musehub/repos/{repo_id}/reactions",
331 json={"target_type": "repo", "target_id": repo_id, "emoji": "👍"},
332 headers=auth_headers,
333 )
334 assert resp.status_code == 201
335 body = resp.json()
336 assert body["added"] is True
337 assert body["emoji"] == "👍"
338
339
340 @pytest.mark.anyio
341 async def test_toggle_reaction_removes_on_second_call(
342 client: AsyncClient,
343 auth_headers: dict[str, str],
344 ) -> None:
345 """Second POST with same emoji removes the reaction (added=False) — idempotent toggle."""
346 repo_id = await _make_repo(client, auth_headers, name="reaction-toggle")
347 payload = {"target_type": "repo", "target_id": repo_id, "emoji": "❤️"}
348 await client.post(f"/api/v1/musehub/repos/{repo_id}/reactions", json=payload, headers=auth_headers)
349 resp = await client.post(f"/api/v1/musehub/repos/{repo_id}/reactions", json=payload, headers=auth_headers)
350 assert resp.status_code == 201
351 assert resp.json()["added"] is False
352
353
354 @pytest.mark.anyio
355 async def test_toggle_reaction_reflects_in_list(
356 client: AsyncClient,
357 auth_headers: dict[str, str],
358 ) -> None:
359 """Reaction count increments after toggle-add and reacted_by_me is True."""
360 repo_id = await _make_repo(client, auth_headers, name="reaction-list")
361 await client.post(
362 f"/api/v1/musehub/repos/{repo_id}/reactions",
363 json={"target_type": "repo", "target_id": repo_id, "emoji": "🔥"},
364 headers=auth_headers,
365 )
366 resp = await client.get(
367 f"/api/v1/musehub/repos/{repo_id}/reactions",
368 params={"target_type": "repo", "target_id": repo_id},
369 headers=auth_headers,
370 )
371 assert resp.status_code == 200
372 counts = resp.json()
373 fire = next((r for r in counts if r["emoji"] == "🔥"), None)
374 assert fire is not None
375 assert fire["count"] == 1
376 assert fire["reacted_by_me"] is True
377
378
379 @pytest.mark.anyio
380 async def test_toggle_reaction_invalid_emoji_returns_400(
381 client: AsyncClient,
382 auth_headers: dict[str, str],
383 ) -> None:
384 """POST /repos/{id}/reactions with an unsupported emoji returns 400."""
385 repo_id = await _make_repo(client, auth_headers, name="reaction-bad-emoji")
386 resp = await client.post(
387 f"/api/v1/musehub/repos/{repo_id}/reactions",
388 json={"target_type": "repo", "target_id": repo_id, "emoji": "🤡"},
389 headers=auth_headers,
390 )
391 assert resp.status_code == 400
392
393
394 @pytest.mark.anyio
395 async def test_toggle_reaction_requires_auth(
396 client: AsyncClient,
397 auth_headers: dict[str, str],
398 ) -> None:
399 """POST /repos/{id}/reactions returns 401 without token."""
400 repo_id = await _make_repo(client, auth_headers, name="reaction-no-auth")
401 resp = await client.post(
402 f"/api/v1/musehub/repos/{repo_id}/reactions",
403 json={"target_type": "repo", "target_id": repo_id, "emoji": "👍"},
404 )
405 assert resp.status_code == 401
406
407
408 # ---------------------------------------------------------------------------
409 # Follow — GET
410 # ---------------------------------------------------------------------------
411
412
413 @pytest.mark.anyio
414 async def test_get_followers_returns_zero_for_new_user(
415 client: AsyncClient,
416 ) -> None:
417 """GET /users/{u}/followers returns 0 followers for a user nobody follows."""
418 resp = await client.get("/api/v1/musehub/users/newbie/followers")
419 assert resp.status_code == 200
420 body = resp.json()
421 assert body["follower_count"] == 0
422 assert body["following"] is False
423
424
425 # ---------------------------------------------------------------------------
426 # Follow — POST
427 # ---------------------------------------------------------------------------
428
429
430 @pytest.mark.anyio
431 async def test_follow_user_returns_201(
432 client: AsyncClient,
433 auth_headers: dict[str, str],
434 ) -> None:
435 """POST /users/{u}/follow returns 201 and following=True."""
436 resp = await client.post("/api/v1/musehub/users/other-musician/follow", headers=auth_headers)
437 assert resp.status_code == 201
438 body = resp.json()
439 assert body["following"] is True
440 assert body["username"] == "other-musician"
441
442
443 @pytest.mark.anyio
444 async def test_follow_user_self_returns_400(
445 client: AsyncClient,
446 auth_headers: dict[str, str],
447 ) -> None:
448 """POST /users/{u}/follow returns 400 when trying to follow yourself."""
449 resp = await client.post(f"/api/v1/musehub/users/{_TEST_USER_ID}/follow", headers=auth_headers)
450 assert resp.status_code == 400
451
452
453 @pytest.mark.anyio
454 async def test_follow_user_requires_auth(client: AsyncClient) -> None:
455 """POST /users/{u}/follow returns 401 without token."""
456 resp = await client.post("/api/v1/musehub/users/someone/follow")
457 assert resp.status_code == 401
458
459
460 @pytest.mark.anyio
461 async def test_follow_user_increments_follower_count(
462 client: AsyncClient,
463 auth_headers: dict[str, str],
464 ) -> None:
465 """Follower count increments after a follow and resets after unfollow."""
466 await client.post("/api/v1/musehub/users/followed-user/follow", headers=auth_headers)
467 resp = await client.get("/api/v1/musehub/users/followed-user/followers", headers=auth_headers)
468 assert resp.status_code == 200
469 assert resp.json()["follower_count"] == 1
470 assert resp.json()["following"] is True
471
472
473 # ---------------------------------------------------------------------------
474 # Follow — DELETE (unfollow)
475 # ---------------------------------------------------------------------------
476
477
478 @pytest.mark.anyio
479 async def test_unfollow_user_returns_204(
480 client: AsyncClient,
481 auth_headers: dict[str, str],
482 ) -> None:
483 """DELETE /users/{u}/follow returns 204 (no-op if not following)."""
484 resp = await client.delete("/api/v1/musehub/users/some-user/follow", headers=auth_headers)
485 assert resp.status_code == 204
486
487
488 @pytest.mark.anyio
489 async def test_unfollow_decrements_count(
490 client: AsyncClient,
491 auth_headers: dict[str, str],
492 ) -> None:
493 """Unfollow reduces the follower count back to 0."""
494 await client.post("/api/v1/musehub/users/temp-follow/follow", headers=auth_headers)
495 await client.delete("/api/v1/musehub/users/temp-follow/follow", headers=auth_headers)
496 resp = await client.get("/api/v1/musehub/users/temp-follow/followers")
497 assert resp.json()["follower_count"] == 0
498
499
500 @pytest.mark.anyio
501 async def test_unfollow_requires_auth(client: AsyncClient) -> None:
502 """DELETE /users/{u}/follow returns 401 without token."""
503 resp = await client.delete("/api/v1/musehub/users/someone/follow")
504 assert resp.status_code == 401
505
506
507 # ---------------------------------------------------------------------------
508 # Watch — GET
509 # ---------------------------------------------------------------------------
510
511
512 @pytest.mark.anyio
513 async def test_get_watches_returns_zero_for_new_repo(
514 client: AsyncClient,
515 auth_headers: dict[str, str],
516 ) -> None:
517 """GET /repos/{id}/watches returns 0 for a new repo."""
518 repo_id = await _make_repo(client, auth_headers, name="watch-empty")
519 resp = await client.get(f"/api/v1/musehub/repos/{repo_id}/watches")
520 assert resp.status_code == 200
521 assert resp.json()["watch_count"] == 0
522 assert resp.json()["watching"] is False
523
524
525 # ---------------------------------------------------------------------------
526 # Watch — POST
527 # ---------------------------------------------------------------------------
528
529
530 @pytest.mark.anyio
531 async def test_watch_repo_returns_201(
532 client: AsyncClient,
533 auth_headers: dict[str, str],
534 ) -> None:
535 """POST /repos/{id}/watch returns 201 and watching=True."""
536 repo_id = await _make_repo(client, auth_headers, name="watch-add")
537 resp = await client.post(f"/api/v1/musehub/repos/{repo_id}/watch", headers=auth_headers)
538 assert resp.status_code == 201
539 assert resp.json()["watching"] is True
540
541
542 @pytest.mark.anyio
543 async def test_watch_repo_idempotent(
544 client: AsyncClient,
545 auth_headers: dict[str, str],
546 ) -> None:
547 """Watching the same repo twice does not raise an error (idempotent)."""
548 repo_id = await _make_repo(client, auth_headers, name="watch-idempotent")
549 await client.post(f"/api/v1/musehub/repos/{repo_id}/watch", headers=auth_headers)
550 resp = await client.post(f"/api/v1/musehub/repos/{repo_id}/watch", headers=auth_headers)
551 assert resp.status_code == 201
552
553
554 @pytest.mark.anyio
555 async def test_watch_requires_auth(
556 client: AsyncClient,
557 auth_headers: dict[str, str],
558 ) -> None:
559 """POST /repos/{id}/watch returns 401 without token."""
560 repo_id = await _make_repo(client, auth_headers, name="watch-no-auth")
561 resp = await client.post(f"/api/v1/musehub/repos/{repo_id}/watch")
562 assert resp.status_code == 401
563
564
565 @pytest.mark.anyio
566 async def test_watch_increments_count(
567 client: AsyncClient,
568 auth_headers: dict[str, str],
569 ) -> None:
570 """Watch count is 1 after a successful watch."""
571 repo_id = await _make_repo(client, auth_headers, name="watch-count")
572 await client.post(f"/api/v1/musehub/repos/{repo_id}/watch", headers=auth_headers)
573 resp = await client.get(f"/api/v1/musehub/repos/{repo_id}/watches", headers=auth_headers)
574 assert resp.json()["watch_count"] == 1
575 assert resp.json()["watching"] is True
576
577
578 # ---------------------------------------------------------------------------
579 # Watch — DELETE (unwatch)
580 # ---------------------------------------------------------------------------
581
582
583 @pytest.mark.anyio
584 async def test_unwatch_repo_returns_204(
585 client: AsyncClient,
586 auth_headers: dict[str, str],
587 ) -> None:
588 """DELETE /repos/{id}/watch returns 204 (no-op if not watching)."""
589 repo_id = await _make_repo(client, auth_headers, name="unwatch-noop")
590 resp = await client.delete(f"/api/v1/musehub/repos/{repo_id}/watch", headers=auth_headers)
591 assert resp.status_code == 204
592
593
594 @pytest.mark.anyio
595 async def test_unwatch_requires_auth(
596 client: AsyncClient,
597 auth_headers: dict[str, str],
598 ) -> None:
599 """DELETE /repos/{id}/watch returns 401 without token."""
600 repo_id = await _make_repo(client, auth_headers, name="unwatch-no-auth")
601 resp = await client.delete(f"/api/v1/musehub/repos/{repo_id}/watch")
602 assert resp.status_code == 401
603
604
605 # ---------------------------------------------------------------------------
606 # Notifications — GET
607 # ---------------------------------------------------------------------------
608
609
610 @pytest.mark.anyio
611 async def test_list_notifications_empty_inbox(
612 client: AsyncClient,
613 auth_headers: dict[str, str],
614 ) -> None:
615 """GET /notifications returns empty list for a user with no notifications."""
616 resp = await client.get("/api/v1/musehub/notifications", headers=auth_headers)
617 assert resp.status_code == 200
618 assert resp.json() == []
619
620
621 @pytest.mark.anyio
622 async def test_list_notifications_requires_auth(client: AsyncClient) -> None:
623 """GET /notifications returns 401 without token."""
624 resp = await client.get("/api/v1/musehub/notifications")
625 assert resp.status_code == 401
626
627
628 @pytest.mark.anyio
629 async def test_list_notifications_returns_own_notifications(
630 client: AsyncClient,
631 auth_headers: dict[str, str],
632 db_session: AsyncSession,
633 ) -> None:
634 """GET /notifications returns notifications addressed to the calling user."""
635 notif = MusehubNotification(
636 notif_id=str(uuid.uuid4()),
637 recipient_id=_TEST_USER_ID,
638 event_type="new_follower",
639 repo_id=None,
640 actor="alice",
641 payload={"msg": "alice followed you"},
642 is_read=False,
643 created_at=datetime.now(tz=timezone.utc),
644 )
645 db_session.add(notif)
646 await db_session.commit()
647
648 resp = await client.get("/api/v1/musehub/notifications", headers=auth_headers)
649 assert resp.status_code == 200
650 items = resp.json()
651 assert len(items) == 1
652 assert items[0]["event_type"] == "new_follower"
653 assert items[0]["is_read"] is False
654
655
656 @pytest.mark.anyio
657 async def test_list_notifications_unread_only_filter(
658 client: AsyncClient,
659 auth_headers: dict[str, str],
660 db_session: AsyncSession,
661 ) -> None:
662 """?unread_only=true filters out already-read notifications."""
663 read_notif = MusehubNotification(
664 notif_id=str(uuid.uuid4()),
665 recipient_id=_TEST_USER_ID,
666 event_type="comment",
667 repo_id=None,
668 actor="bob",
669 payload={},
670 is_read=True,
671 created_at=datetime.now(tz=timezone.utc),
672 )
673 unread_notif = MusehubNotification(
674 notif_id=str(uuid.uuid4()),
675 recipient_id=_TEST_USER_ID,
676 event_type="mention",
677 repo_id=None,
678 actor="carol",
679 payload={},
680 is_read=False,
681 created_at=datetime.now(tz=timezone.utc),
682 )
683 db_session.add_all([read_notif, unread_notif])
684 await db_session.commit()
685
686 resp = await client.get("/api/v1/musehub/notifications?unread_only=true", headers=auth_headers)
687 assert resp.status_code == 200
688 items = resp.json()
689 assert len(items) == 1
690 assert items[0]["event_type"] == "mention"
691
692
693 # ---------------------------------------------------------------------------
694 # Notifications — POST mark read
695 # ---------------------------------------------------------------------------
696
697
698 @pytest.mark.anyio
699 async def test_mark_notification_read(
700 client: AsyncClient,
701 auth_headers: dict[str, str],
702 db_session: AsyncSession,
703 ) -> None:
704 """POST /notifications/{id}/read marks the notification as read."""
705 notif_id = str(uuid.uuid4())
706 notif = MusehubNotification(
707 notif_id=notif_id,
708 recipient_id=_TEST_USER_ID,
709 event_type="pr_opened",
710 repo_id=None,
711 actor="dave",
712 payload={},
713 is_read=False,
714 created_at=datetime.now(tz=timezone.utc),
715 )
716 db_session.add(notif)
717 await db_session.commit()
718
719 resp = await client.post(f"/api/v1/musehub/notifications/{notif_id}/read", headers=auth_headers)
720 assert resp.status_code == 200
721 body = resp.json()
722 assert body["read"] is True
723 assert body["notif_id"] == notif_id
724
725
726 @pytest.mark.anyio
727 async def test_mark_notification_read_not_found(
728 client: AsyncClient,
729 auth_headers: dict[str, str],
730 ) -> None:
731 """POST /notifications/{id}/read returns 404 for unknown notification."""
732 resp = await client.post(
733 f"/api/v1/musehub/notifications/{uuid.uuid4()}/read",
734 headers=auth_headers,
735 )
736 assert resp.status_code == 404
737
738
739 @pytest.mark.anyio
740 async def test_mark_notification_read_requires_auth(client: AsyncClient) -> None:
741 """POST /notifications/{id}/read returns 401 without token."""
742 resp = await client.post(f"/api/v1/musehub/notifications/{uuid.uuid4()}/read")
743 assert resp.status_code == 401
744
745
746 # ---------------------------------------------------------------------------
747 # Notifications — POST read-all
748 # ---------------------------------------------------------------------------
749
750
751 @pytest.mark.anyio
752 async def test_mark_all_notifications_read(
753 client: AsyncClient,
754 auth_headers: dict[str, str],
755 db_session: AsyncSession,
756 ) -> None:
757 """POST /notifications/read-all marks all unread notifications and returns count."""
758 for i in range(3):
759 db_session.add(
760 MusehubNotification(
761 notif_id=str(uuid.uuid4()),
762 recipient_id=_TEST_USER_ID,
763 event_type="comment",
764 repo_id=None,
765 actor=f"user-{i}",
766 payload={},
767 is_read=False,
768 created_at=datetime.now(tz=timezone.utc),
769 )
770 )
771 await db_session.commit()
772
773 resp = await client.post("/api/v1/musehub/notifications/read-all", headers=auth_headers)
774 assert resp.status_code == 200
775 assert resp.json()["marked_read"] == 3
776
777
778 @pytest.mark.anyio
779 async def test_mark_all_notifications_requires_auth(client: AsyncClient) -> None:
780 """POST /notifications/read-all returns 401 without token."""
781 resp = await client.post("/api/v1/musehub/notifications/read-all")
782 assert resp.status_code == 401
783
784
785 # ---------------------------------------------------------------------------
786 # Forks — GET
787 # ---------------------------------------------------------------------------
788
789
790 @pytest.mark.anyio
791 async def test_list_forks_empty_on_new_repo(
792 client: AsyncClient,
793 auth_headers: dict[str, str],
794 ) -> None:
795 """GET /repos/{id}/forks returns empty list when repo has no forks."""
796 repo_id = await _make_repo(client, auth_headers, name="fork-list-empty")
797 resp = await client.get(f"/api/v1/musehub/repos/{repo_id}/forks", headers=auth_headers)
798 assert resp.status_code == 200
799 assert resp.json() == []
800
801
802 @pytest.mark.anyio
803 async def test_list_forks_private_repo_requires_auth(
804 client: AsyncClient,
805 db_session: AsyncSession,
806 ) -> None:
807 """GET /repos/{id}/forks returns 401 for private repo without auth."""
808 repo_id = await _make_private_repo(db_session, name="fork-priv-list")
809 resp = await client.get(f"/api/v1/musehub/repos/{repo_id}/forks")
810 assert resp.status_code == 401
811
812
813 @pytest.mark.anyio
814 async def test_list_forks_contains_fork_record(
815 client: AsyncClient,
816 auth_headers: dict[str, str],
817 db_session: AsyncSession,
818 ) -> None:
819 """GET /repos/{id}/forks lists a fork after it has been created."""
820 repo_id = await _make_repo(client, auth_headers, name="forkable")
821 fork_resp = await client.post(f"/api/v1/musehub/repos/{repo_id}/fork", headers=auth_headers)
822 assert fork_resp.status_code == 201
823
824 resp = await client.get(f"/api/v1/musehub/repos/{repo_id}/forks", headers=auth_headers)
825 assert resp.status_code == 200
826 forks = resp.json()
827 assert len(forks) == 1
828 assert forks[0]["source_repo_id"] == repo_id
829
830
831 # ---------------------------------------------------------------------------
832 # Forks — POST
833 # ---------------------------------------------------------------------------
834
835
836 @pytest.mark.anyio
837 async def test_fork_public_repo_returns_201(
838 client: AsyncClient,
839 auth_headers: dict[str, str],
840 ) -> None:
841 """POST /repos/{id}/fork forks a public repo and returns 201 with fork fields."""
842 repo_id = await _make_repo(client, auth_headers, name="public-fork-src")
843 resp = await client.post(f"/api/v1/musehub/repos/{repo_id}/fork", headers=auth_headers)
844 assert resp.status_code == 201
845 body = resp.json()
846 assert body["source_repo_id"] == repo_id
847 assert body["forked_by"] == _TEST_USER_ID
848 assert "fork_id" in body
849 assert "fork_repo_id" in body
850
851
852 @pytest.mark.anyio
853 async def test_fork_private_repo_returns_403(
854 client: AsyncClient,
855 auth_headers: dict[str, str],
856 db_session: AsyncSession,
857 ) -> None:
858 """POST /repos/{id}/fork returns 403 when source repo is private."""
859 repo_id = await _make_private_repo(db_session, name="private-fork-src")
860 resp = await client.post(f"/api/v1/musehub/repos/{repo_id}/fork", headers=auth_headers)
861 assert resp.status_code == 403
862
863
864 @pytest.mark.anyio
865 async def test_fork_requires_auth(
866 client: AsyncClient,
867 auth_headers: dict[str, str],
868 ) -> None:
869 """POST /repos/{id}/fork returns 401 without token."""
870 repo_id = await _make_repo(client, auth_headers, name="fork-no-auth")
871 resp = await client.post(f"/api/v1/musehub/repos/{repo_id}/fork")
872 assert resp.status_code == 401
873
874
875 @pytest.mark.anyio
876 async def test_fork_nonexistent_repo_returns_404(
877 client: AsyncClient,
878 auth_headers: dict[str, str],
879 ) -> None:
880 """POST /repos/{id}/fork returns 404 when source repo does not exist."""
881 resp = await client.post("/api/v1/musehub/repos/no-such-repo/fork", headers=auth_headers)
882 assert resp.status_code == 404
883
884
885 # ---------------------------------------------------------------------------
886 # Analytics — record view (debounce)
887 # ---------------------------------------------------------------------------
888
889
890 @pytest.mark.anyio
891 async def test_record_view_returns_204(
892 client: AsyncClient,
893 auth_headers: dict[str, str],
894 ) -> None:
895 """POST /repos/{id}/view returns 204 on success."""
896 repo_id = await _make_repo(client, auth_headers, name="view-record")
897 resp = await client.post(f"/api/v1/musehub/repos/{repo_id}/view")
898 assert resp.status_code == 204
899
900
901 @pytest.mark.anyio
902 async def test_record_view_debounce_no_duplicate(
903 client: AsyncClient,
904 auth_headers: dict[str, str],
905 db_session: AsyncSession,
906 ) -> None:
907 """Duplicate (repo, fingerprint, date) inserts do not return 500 — debounced silently."""
908 repo_id = await _make_repo(client, auth_headers, name="view-debounce")
909
910 # Seed an existing view event with the same fingerprint that the test client will produce
911 import hashlib
912 from datetime import date
913
914 fingerprint = hashlib.sha256(b"testclient").hexdigest()[:64]
915 today = date.today().isoformat()
916 existing = MusehubViewEvent(
917 view_id=str(uuid.uuid4()),
918 repo_id=repo_id,
919 viewer_fingerprint=fingerprint,
920 event_date=today,
921 )
922 db_session.add(existing)
923 await db_session.commit()
924
925 # Posting again should silently no-op, not 500
926 resp = await client.post(f"/api/v1/musehub/repos/{repo_id}/view")
927 assert resp.status_code == 204
928
929
930 # ---------------------------------------------------------------------------
931 # Analytics — summary
932 # ---------------------------------------------------------------------------
933
934
935 @pytest.mark.anyio
936 async def test_get_analytics_public_repo(
937 client: AsyncClient,
938 auth_headers: dict[str, str],
939 ) -> None:
940 """GET /repos/{id}/analytics returns view/download counts for a public repo."""
941 repo_id = await _make_repo(client, auth_headers, name="analytics-pub")
942 resp = await client.get(f"/api/v1/musehub/repos/{repo_id}/analytics", headers=auth_headers)
943 assert resp.status_code == 200
944 body = resp.json()
945 assert body["repo_id"] == repo_id
946 assert body["view_count"] == 0
947 assert body["download_count"] == 0
948
949
950 @pytest.mark.anyio
951 async def test_get_analytics_private_repo_requires_auth(
952 client: AsyncClient,
953 db_session: AsyncSession,
954 ) -> None:
955 """GET /repos/{id}/analytics returns 401 for private repo without auth."""
956 repo_id = await _make_private_repo(db_session, name="analytics-priv")
957 resp = await client.get(f"/api/v1/musehub/repos/{repo_id}/analytics")
958 assert resp.status_code == 401
959
960
961 @pytest.mark.anyio
962 async def test_get_analytics_not_found_returns_404(
963 client: AsyncClient,
964 auth_headers: dict[str, str],
965 ) -> None:
966 """GET /repos/{id}/analytics returns 404 for unknown repo."""
967 resp = await client.get("/api/v1/musehub/repos/ghost-repo/analytics", headers=auth_headers)
968 assert resp.status_code == 404
969
970
971 @pytest.mark.anyio
972 async def test_get_analytics_view_count_after_record(
973 client: AsyncClient,
974 auth_headers: dict[str, str],
975 db_session: AsyncSession,
976 ) -> None:
977 """View count reflects recorded view events."""
978 repo_id = await _make_repo(client, auth_headers, name="analytics-count")
979 db_session.add(
980 MusehubViewEvent(
981 view_id=str(uuid.uuid4()),
982 repo_id=repo_id,
983 viewer_fingerprint="fp-abc",
984 event_date="2026-01-01",
985 )
986 )
987 await db_session.commit()
988
989 resp = await client.get(f"/api/v1/musehub/repos/{repo_id}/analytics", headers=auth_headers)
990 assert resp.status_code == 200
991 assert resp.json()["view_count"] == 1
992
993
994 # ---------------------------------------------------------------------------
995 # Analytics — daily views
996 # ---------------------------------------------------------------------------
997
998
999 @pytest.mark.anyio
1000 async def test_get_view_analytics_empty(
1001 client: AsyncClient,
1002 auth_headers: dict[str, str],
1003 ) -> None:
1004 """GET /repos/{id}/analytics/views returns empty list for repo with no views."""
1005 repo_id = await _make_repo(client, auth_headers, name="daily-empty")
1006 resp = await client.get(
1007 f"/api/v1/musehub/repos/{repo_id}/analytics/views", headers=auth_headers
1008 )
1009 assert resp.status_code == 200
1010 assert resp.json() == []
1011
1012
1013 @pytest.mark.anyio
1014 async def test_get_view_analytics_aggregates_by_day(
1015 client: AsyncClient,
1016 auth_headers: dict[str, str],
1017 db_session: AsyncSession,
1018 ) -> None:
1019 """GET /repos/{id}/analytics/views aggregates view events by event_date."""
1020 repo_id = await _make_repo(client, auth_headers, name="daily-agg")
1021 db_session.add_all([
1022 MusehubViewEvent(
1023 view_id=str(uuid.uuid4()),
1024 repo_id=repo_id,
1025 viewer_fingerprint="fp-x",
1026 event_date="2026-02-01",
1027 ),
1028 MusehubViewEvent(
1029 view_id=str(uuid.uuid4()),
1030 repo_id=repo_id,
1031 viewer_fingerprint="fp-y",
1032 event_date="2026-02-01",
1033 ),
1034 MusehubViewEvent(
1035 view_id=str(uuid.uuid4()),
1036 repo_id=repo_id,
1037 viewer_fingerprint="fp-z",
1038 event_date="2026-02-02",
1039 ),
1040 ])
1041 await db_session.commit()
1042
1043 resp = await client.get(
1044 f"/api/v1/musehub/repos/{repo_id}/analytics/views?days=90",
1045 headers=auth_headers,
1046 )
1047 assert resp.status_code == 200
1048 results = resp.json()
1049 by_date = {r["date"]: r["count"] for r in results}
1050 assert by_date.get("2026-02-01") == 2
1051 assert by_date.get("2026-02-02") == 1
1052
1053
1054 # ---------------------------------------------------------------------------
1055 # Feed
1056 # ---------------------------------------------------------------------------
1057
1058
1059 @pytest.mark.anyio
1060 async def test_get_feed_empty(
1061 client: AsyncClient,
1062 auth_headers: dict[str, str],
1063 ) -> None:
1064 """GET /feed returns empty list for a user with no activity."""
1065 resp = await client.get("/api/v1/musehub/feed", headers=auth_headers)
1066 assert resp.status_code == 200
1067 assert resp.json() == []
1068
1069
1070 @pytest.mark.anyio
1071 async def test_get_feed_requires_auth(client: AsyncClient) -> None:
1072 """GET /feed returns 401 without token."""
1073 resp = await client.get("/api/v1/musehub/feed")
1074 assert resp.status_code == 401
1075
1076
1077 @pytest.mark.anyio
1078 async def test_get_feed_returns_user_notifications(
1079 client: AsyncClient,
1080 auth_headers: dict[str, str],
1081 db_session: AsyncSession,
1082 ) -> None:
1083 """GET /feed returns notifications for the calling user, newest first."""
1084 from datetime import timedelta
1085
1086 now = datetime.now(tz=timezone.utc)
1087 older = MusehubNotification(
1088 notif_id=str(uuid.uuid4()),
1089 recipient_id=_TEST_USER_ID,
1090 event_type="comment",
1091 repo_id=None,
1092 actor="eve",
1093 payload={},
1094 is_read=False,
1095 created_at=now - timedelta(hours=2),
1096 )
1097 newer = MusehubNotification(
1098 notif_id=str(uuid.uuid4()),
1099 recipient_id=_TEST_USER_ID,
1100 event_type="mention",
1101 repo_id=None,
1102 actor="frank",
1103 payload={},
1104 is_read=False,
1105 created_at=now - timedelta(hours=1),
1106 )
1107 db_session.add_all([older, newer])
1108 await db_session.commit()
1109
1110 resp = await client.get("/api/v1/musehub/feed", headers=auth_headers)
1111 assert resp.status_code == 200
1112 items = resp.json()
1113 assert len(items) == 2
1114 assert items[0]["event_type"] == "mention" # newest first
1115 assert items[1]["event_type"] == "comment"
1116
1117
1118 # ---------------------------------------------------------------------------
1119 # Analytics — social trends (stars, forks, watches)
1120 # ---------------------------------------------------------------------------
1121
1122
1123 @pytest.mark.anyio
1124 async def test_get_social_analytics_empty_public_repo(
1125 client: AsyncClient,
1126 auth_headers: dict[str, str],
1127 ) -> None:
1128 """GET /repos/{id}/analytics/social returns zero totals for a new public repo."""
1129 repo_id = await _make_repo(client, auth_headers, name="social-analytics-empty")
1130 resp = await client.get(
1131 f"/api/v1/musehub/repos/{repo_id}/analytics/social",
1132 headers=auth_headers,
1133 )
1134 assert resp.status_code == 200
1135 body = resp.json()
1136 assert body["star_count"] == 0
1137 assert body["fork_count"] == 0
1138 assert body["watch_count"] == 0
1139 assert isinstance(body["trend"], list)
1140 assert isinstance(body["forks_detail"], list)
1141
1142
1143 @pytest.mark.anyio
1144 async def test_get_social_analytics_not_found_returns_404(
1145 client: AsyncClient,
1146 auth_headers: dict[str, str],
1147 ) -> None:
1148 """GET /repos/{id}/analytics/social returns 404 for an unknown repo."""
1149 resp = await client.get(
1150 "/api/v1/musehub/repos/does-not-exist/analytics/social",
1151 headers=auth_headers,
1152 )
1153 assert resp.status_code == 404
1154
1155
1156 @pytest.mark.anyio
1157 async def test_get_social_analytics_private_repo_requires_auth(
1158 client: AsyncClient,
1159 db_session: AsyncSession,
1160 ) -> None:
1161 """GET /repos/{id}/analytics/social returns 401 for a private repo without auth."""
1162 repo_id = await _make_private_repo(db_session, name="social-analytics-priv")
1163 resp = await client.get(f"/api/v1/musehub/repos/{repo_id}/analytics/social")
1164 assert resp.status_code == 401
1165
1166
1167 @pytest.mark.anyio
1168 async def test_get_social_analytics_counts_seeded_rows(
1169 client: AsyncClient,
1170 auth_headers: dict[str, str],
1171 db_session: AsyncSession,
1172 ) -> None:
1173 """GET /repos/{id}/analytics/social reflects seeded star, fork, and watch rows."""
1174 from datetime import timedelta
1175
1176 repo_id = await _make_repo(client, auth_headers, name="social-analytics-counts")
1177 now = datetime.now(tz=timezone.utc)
1178
1179 # Seed one star, one watch
1180 star = MusehubStar(
1181 star_id=str(uuid.uuid4()),
1182 repo_id=repo_id,
1183 user_id="user-a",
1184 created_at=now,
1185 )
1186 watch = MusehubWatch(
1187 watch_id=str(uuid.uuid4()),
1188 user_id="user-b",
1189 repo_id=repo_id,
1190 created_at=now,
1191 )
1192 db_session.add_all([star, watch])
1193 await db_session.commit()
1194
1195 resp = await client.get(
1196 f"/api/v1/musehub/repos/{repo_id}/analytics/social?days=90",
1197 headers=auth_headers,
1198 )
1199 assert resp.status_code == 200
1200 body = resp.json()
1201 assert body["star_count"] == 1
1202 assert body["watch_count"] == 1
1203 assert body["fork_count"] == 0
1204
1205
1206 @pytest.mark.anyio
1207 async def test_get_social_analytics_trend_spans_full_window(
1208 client: AsyncClient,
1209 auth_headers: dict[str, str],
1210 ) -> None:
1211 """GET /repos/{id}/analytics/social trend list spans exactly 'days' entries."""
1212 repo_id = await _make_repo(client, auth_headers, name="social-analytics-window")
1213 resp = await client.get(
1214 f"/api/v1/musehub/repos/{repo_id}/analytics/social?days=30",
1215 headers=auth_headers,
1216 )
1217 assert resp.status_code == 200
1218 body = resp.json()
1219 # Trend must contain exactly `days` entries, one per calendar day
1220 assert len(body["trend"]) == 30
1221 # All entries must be zero for a new repo
1222 for day in body["trend"]:
1223 assert day["stars"] == 0
1224 assert day["forks"] == 0
1225 assert day["watches"] == 0
1226
1227
1228 @pytest.mark.anyio
1229 async def test_get_social_analytics_forks_detail_includes_forked_by(
1230 client: AsyncClient,
1231 auth_headers: dict[str, str],
1232 db_session: AsyncSession,
1233 ) -> None:
1234 """GET /repos/{id}/analytics/social forks_detail lists who forked the repo."""
1235 repo_id = await _make_repo(client, auth_headers, name="social-analytics-forks")
1236
1237 # Seed a fork record directly (no need to create the fork repo for this assertion)
1238 fork_repo = MusehubRepo(
1239 name="forked-copy",
1240 owner="alice",
1241 slug="forked-copy",
1242 visibility="public",
1243 owner_user_id="user-alice",
1244 )
1245 db_session.add(fork_repo)
1246 await db_session.flush()
1247
1248 fork = MusehubFork(
1249 fork_id=str(uuid.uuid4()),
1250 source_repo_id=repo_id,
1251 fork_repo_id=str(fork_repo.repo_id),
1252 forked_by="alice",
1253 created_at=datetime.now(tz=timezone.utc),
1254 )
1255 db_session.add(fork)
1256 await db_session.commit()
1257
1258 resp = await client.get(
1259 f"/api/v1/musehub/repos/{repo_id}/analytics/social",
1260 headers=auth_headers,
1261 )
1262 assert resp.status_code == 200
1263 body = resp.json()
1264 assert body["fork_count"] == 1
1265 assert len(body["forks_detail"]) == 1
1266 assert body["forks_detail"][0]["forked_by"] == "alice"