gabriel / musehub public
test_musehub_ui_notifications_ssr.py python
156 lines 5.3 KB
cd448303 Initial extraction of MuseHub from maestro monorepo. Gabriel Cardona <gabriel@tellurstori.com> 7d ago
1 """SSR-specific tests for the MuseHub notification inbox (ui_notifications.py).
2
3 Covers the SSR migration at GET /musehub/ui/notifications:
4
5 - test_notifications_page_unauthenticated_renders_login_prompt — login prompt rendered without token
6 - test_notifications_page_renders_notification_server_side — authenticated GET includes notif body
7 - test_notifications_filter_type_narrows_results — ?type_filter=issue returns only issue notifs
8 - test_notifications_unread_only_filter — ?unread_only=true returns only unread notifs
9 - test_notifications_htmx_request_returns_fragment — HX-Request: true → fragment only (no <html>)
10 """
11 from __future__ import annotations
12
13 import uuid
14 from datetime import datetime, timezone
15
16 import pytest
17 from httpx import AsyncClient
18 from sqlalchemy.ext.asyncio import AsyncSession
19
20 from musehub.db.musehub_models import MusehubNotification
21
22 _TEST_USER_ID = "550e8400-e29b-41d4-a716-446655440000"
23 _UI_PATH = "/musehub/ui/notifications"
24
25
26 # ---------------------------------------------------------------------------
27 # Seed helpers
28 # ---------------------------------------------------------------------------
29
30
31 def _make_notif(
32 recipient_id: str,
33 event_type: str = "mention",
34 is_read: bool = False,
35 actor: str = "test-actor",
36 repo_id: str | None = None,
37 ) -> MusehubNotification:
38 return MusehubNotification(
39 notif_id=str(uuid.uuid4()),
40 recipient_id=recipient_id,
41 event_type=event_type,
42 repo_id=repo_id or str(uuid.uuid4()),
43 actor=actor,
44 payload={"description": f"did {event_type}"},
45 is_read=is_read,
46 created_at=datetime.now(tz=timezone.utc),
47 )
48
49
50 async def _seed(db: AsyncSession, *notifs: MusehubNotification) -> None:
51 for n in notifs:
52 db.add(n)
53 await db.commit()
54
55
56 # ---------------------------------------------------------------------------
57 # SSR — unauthenticated
58 # ---------------------------------------------------------------------------
59
60
61 @pytest.mark.anyio
62 async def test_notifications_page_unauthenticated_renders_login_prompt(
63 client: AsyncClient,
64 ) -> None:
65 """GET without token renders SSR login prompt (no data fetch, no JS shell)."""
66 resp = await client.get(_UI_PATH)
67 assert resp.status_code == 200
68 assert "text/html" in resp.headers["content-type"]
69 assert "Sign in to see notifications" in resp.text
70 # Must NOT render the filter form or notification rows — those are auth-gated
71 assert "notification-rows" not in resp.text
72 assert "hx-get" not in resp.text
73
74
75 # ---------------------------------------------------------------------------
76 # SSR — authenticated
77 # ---------------------------------------------------------------------------
78
79
80 @pytest.mark.anyio
81 async def test_notifications_page_renders_notification_server_side(
82 client: AsyncClient,
83 auth_headers: dict[str, str],
84 db_session: AsyncSession,
85 ) -> None:
86 """Authenticated GET renders a seeded notification body in the HTML response."""
87 await _seed(db_session, _make_notif(_TEST_USER_ID, actor="alice", event_type="mention"))
88
89 resp = await client.get(_UI_PATH, headers=auth_headers)
90 assert resp.status_code == 200
91 assert "text/html" in resp.headers["content-type"]
92 # The actor name and event type must appear in the SSR output
93 assert "alice" in resp.text
94 assert "mention" in resp.text
95
96
97 @pytest.mark.anyio
98 async def test_notifications_filter_type_narrows_results(
99 client: AsyncClient,
100 auth_headers: dict[str, str],
101 db_session: AsyncSession,
102 ) -> None:
103 """?type_filter=issue renders only issue-type notifications in the HTML."""
104 await _seed(
105 db_session,
106 _make_notif(_TEST_USER_ID, event_type="issue_opened", actor="bob"),
107 _make_notif(_TEST_USER_ID, event_type="fork", actor="carol"),
108 )
109
110 resp = await client.get(
111 _UI_PATH, params={"type_filter": "issue_opened"}, headers=auth_headers
112 )
113 assert resp.status_code == 200
114 assert "bob" in resp.text
115 assert "carol" not in resp.text
116
117
118 @pytest.mark.anyio
119 async def test_notifications_unread_only_filter(
120 client: AsyncClient,
121 auth_headers: dict[str, str],
122 db_session: AsyncSession,
123 ) -> None:
124 """?unread_only=true renders only unread notifications."""
125 await _seed(
126 db_session,
127 _make_notif(_TEST_USER_ID, is_read=False, actor="alice-unread"),
128 _make_notif(_TEST_USER_ID, is_read=True, actor="bob-already-read"),
129 )
130
131 resp = await client.get(
132 _UI_PATH, params={"unread_only": "true"}, headers=auth_headers
133 )
134 assert resp.status_code == 200
135 assert "alice-unread" in resp.text
136 assert "bob-already-read" not in resp.text
137
138
139 @pytest.mark.anyio
140 async def test_notifications_htmx_request_returns_fragment(
141 client: AsyncClient,
142 auth_headers: dict[str, str],
143 db_session: AsyncSession,
144 ) -> None:
145 """HX-Request: true causes the handler to return only the fragment, not the full page."""
146 await _seed(db_session, _make_notif(_TEST_USER_ID, actor="frag-actor"))
147
148 htmx_headers = {**auth_headers, "HX-Request": "true"}
149 resp = await client.get(_UI_PATH, headers=htmx_headers)
150 assert resp.status_code == 200
151 assert "text/html" in resp.headers["content-type"]
152 # Fragment must NOT include full-page chrome
153 assert "<html" not in resp.text
154 assert "<head" not in resp.text
155 # But must include notification content
156 assert "frag-actor" in resp.text