gabriel / musehub public
test_musehub_ui_notifications.py python
399 lines 13.1 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """Tests for the MuseHub notification inbox UI page (ui_notifications.py).
2
3 Covers — GET /notifications:
4
5 HTML page (SSR):
6 - test_notifications_page_returns_200_html — page renders without auth
7 - test_notifications_page_unauthenticated_shows_login — unauthenticated → SSR login prompt
8 - test_notifications_page_authenticated_has_filter_form — HTMX filter form present
9 - test_notifications_page_authenticated_has_notification_rows — rows container present
10 - test_notifications_page_authenticated_has_pagination — pagination present
11
12 JSON alternate (authenticated):
13 - test_notifications_json_requires_auth — JSON path returns 401 without token
14 - test_notifications_json_returns_empty_inbox — authenticated user with no notifs
15 - test_notifications_json_pagination — per_page / page respected
16 - test_notifications_json_type_filter_mention — type=mention filters by event_type
17 - test_notifications_json_type_filter_watch — type=watch filters by event_type
18 - test_notifications_json_type_filter_fork — type=fork filters by event_type
19 - test_notifications_json_unread_only — unread_only=true excludes read items
20 - test_notifications_json_mark_one_read_reflected — read status respected in JSON response
21 - test_notifications_json_unread_count_global — unread_count not scoped by type filter
22 - test_notifications_json_accept_header — Accept: application/json triggers JSON path
23 - test_notifications_json_pagination_metadata — total / total_pages / page in response
24 - test_notifications_json_empty_state_structure — empty inbox returns valid schema
25 """
26 from __future__ import annotations
27
28 import uuid
29 from datetime import datetime, timezone
30
31 import pytest
32 from httpx import AsyncClient
33 from sqlalchemy.ext.asyncio import AsyncSession
34
35 from musehub.db.musehub_models import MusehubNotification
36
37 _TEST_USER_ID = "550e8400-e29b-41d4-a716-446655440000"
38 _UI_PATH = "/notifications"
39
40
41 # ---------------------------------------------------------------------------
42 # Seed helpers
43 # ---------------------------------------------------------------------------
44
45
46 def _make_notif(
47 recipient_id: str,
48 event_type: str = "mention",
49 is_read: bool = False,
50 repo_id: str | None = None,
51 ) -> MusehubNotification:
52 return MusehubNotification(
53 notif_id=str(uuid.uuid4()),
54 recipient_id=recipient_id,
55 event_type=event_type,
56 repo_id=repo_id or str(uuid.uuid4()),
57 actor="some-actor",
58 payload={"ref": "main"},
59 is_read=is_read,
60 created_at=datetime.now(tz=timezone.utc),
61 )
62
63
64 async def _seed(db: AsyncSession, *notifs: MusehubNotification) -> None:
65 for n in notifs:
66 db.add(n)
67 await db.commit()
68
69
70 # ---------------------------------------------------------------------------
71 # HTML page — SSR behavior
72 # ---------------------------------------------------------------------------
73
74
75 @pytest.mark.anyio
76 async def test_notifications_page_returns_200_html(client: AsyncClient) -> None:
77 """GET /notifications returns 200 HTML without auth."""
78 resp = await client.get(_UI_PATH)
79 assert resp.status_code == 200
80 assert "text/html" in resp.headers["content-type"]
81
82
83 @pytest.mark.anyio
84 async def test_notifications_page_unauthenticated_shows_login(client: AsyncClient) -> None:
85 """Unauthenticated GET renders SSR login prompt, not a JS shell."""
86 resp = await client.get(_UI_PATH)
87 assert resp.status_code == 200
88 assert "Sign in to see notifications" in resp.text
89
90
91 @pytest.mark.anyio
92 async def test_notifications_page_authenticated_has_filter_form(
93 client: AsyncClient,
94 auth_headers: dict[str, str],
95 ) -> None:
96 """Authenticated page includes HTMX filter form targeting #notification-rows."""
97 resp = await client.get(_UI_PATH, headers=auth_headers)
98 assert resp.status_code == 200
99 assert "hx-get" in resp.text
100 assert "notification-rows" in resp.text
101
102
103 @pytest.mark.anyio
104 async def test_notifications_page_authenticated_has_notification_rows(
105 client: AsyncClient,
106 auth_headers: dict[str, str],
107 ) -> None:
108 """Authenticated page includes the #notification-rows container for HTMX swaps."""
109 resp = await client.get(_UI_PATH, headers=auth_headers)
110 assert resp.status_code == 200
111 assert "notification-rows" in resp.text
112
113
114 @pytest.mark.anyio
115 async def test_notifications_page_authenticated_has_pagination(
116 client: AsyncClient,
117 auth_headers: dict[str, str],
118 ) -> None:
119 """Authenticated page includes server-side pagination (not JS renderPagination)."""
120 resp = await client.get(_UI_PATH, headers=auth_headers)
121 assert resp.status_code == 200
122 # SSR pagination macro renders page/of HTML; no JS renderPagination call needed
123 assert "Notifications" in resp.text
124
125
126 # ---------------------------------------------------------------------------
127 # JSON alternate — unauthenticated guard
128 # ---------------------------------------------------------------------------
129
130
131 @pytest.mark.anyio
132 async def test_notifications_json_requires_auth(client: AsyncClient) -> None:
133 """JSON path returns 401 when no bearer token is provided."""
134 resp = await client.get(_UI_PATH, params={"format": "json"})
135 assert resp.status_code == 401
136
137
138 @pytest.mark.anyio
139 async def test_notifications_json_requires_auth_accept_header(
140 client: AsyncClient,
141 ) -> None:
142 """JSON path via Accept header also returns 401 without auth."""
143 resp = await client.get(
144 _UI_PATH, headers={"Accept": "application/json"}
145 )
146 assert resp.status_code == 401
147
148
149 # ---------------------------------------------------------------------------
150 # JSON alternate — authenticated
151 # ---------------------------------------------------------------------------
152
153
154 @pytest.mark.anyio
155 async def test_notifications_json_returns_empty_inbox(
156 client: AsyncClient,
157 auth_headers: dict[str, str],
158 ) -> None:
159 """Authenticated user with no notifications gets empty inbox."""
160 resp = await client.get(
161 _UI_PATH, params={"format": "json"}, headers=auth_headers
162 )
163 assert resp.status_code == 200
164 data = resp.json()
165 assert data["notifications"] == []
166 assert data["total"] == 0
167 assert data["unreadCount"] == 0
168
169
170 @pytest.mark.anyio
171 async def test_notifications_json_empty_state_structure(
172 client: AsyncClient,
173 auth_headers: dict[str, str],
174 ) -> None:
175 """Empty inbox JSON response has all required schema fields."""
176 resp = await client.get(
177 _UI_PATH, params={"format": "json"}, headers=auth_headers
178 )
179 assert resp.status_code == 200
180 data = resp.json()
181 required_fields = {
182 "notifications", "total", "page", "perPage",
183 "totalPages", "unreadCount", "typeFilter", "unreadOnly",
184 }
185 assert required_fields <= set(data.keys()), f"Missing fields: {required_fields - set(data.keys())}"
186
187
188 @pytest.mark.anyio
189 async def test_notifications_json_pagination(
190 client: AsyncClient,
191 auth_headers: dict[str, str],
192 db_session: AsyncSession,
193 ) -> None:
194 """per_page and page query params are respected; page 2 returns correct slice."""
195 await _seed(db_session, *[_make_notif(_TEST_USER_ID) for _ in range(5)])
196
197 resp = await client.get(
198 _UI_PATH,
199 params={"format": "json", "per_page": 2, "page": 2},
200 headers=auth_headers,
201 )
202 assert resp.status_code == 200
203 data = resp.json()
204 assert data["page"] == 2
205 assert data["perPage"] == 2
206 assert len(data["notifications"]) == 2
207 assert data["total"] == 5
208 assert data["totalPages"] == 3
209
210
211 @pytest.mark.anyio
212 async def test_notifications_json_pagination_metadata(
213 client: AsyncClient,
214 auth_headers: dict[str, str],
215 db_session: AsyncSession,
216 ) -> None:
217 """total / total_pages / page metadata is correct on a single-page inbox."""
218 await _seed(db_session, _make_notif(_TEST_USER_ID))
219
220 resp = await client.get(
221 _UI_PATH, params={"format": "json"}, headers=auth_headers
222 )
223 assert resp.status_code == 200
224 data = resp.json()
225 assert data["total"] == 1
226 assert data["totalPages"] == 1
227 assert data["page"] == 1
228
229
230 @pytest.mark.anyio
231 async def test_notifications_json_type_filter_mention(
232 client: AsyncClient,
233 auth_headers: dict[str, str],
234 db_session: AsyncSession,
235 ) -> None:
236 """type=mention returns only mention events."""
237 await _seed(
238 db_session,
239 _make_notif(_TEST_USER_ID, event_type="mention"),
240 _make_notif(_TEST_USER_ID, event_type="fork"),
241 )
242 resp = await client.get(
243 _UI_PATH,
244 params={"format": "json", "type_filter": "mention"},
245 headers=auth_headers,
246 )
247 assert resp.status_code == 200
248 data = resp.json()
249 assert data["total"] == 1
250 assert all(n["eventType"] == "mention" for n in data["notifications"])
251 assert data["typeFilter"] == "mention"
252
253
254 @pytest.mark.anyio
255 async def test_notifications_json_type_filter_watch(
256 client: AsyncClient,
257 auth_headers: dict[str, str],
258 db_session: AsyncSession,
259 ) -> None:
260 """type=watch returns only watch events."""
261 await _seed(
262 db_session,
263 _make_notif(_TEST_USER_ID, event_type="watch"),
264 _make_notif(_TEST_USER_ID, event_type="comment"),
265 )
266 resp = await client.get(
267 _UI_PATH,
268 params={"format": "json", "type_filter": "watch"},
269 headers=auth_headers,
270 )
271 assert resp.status_code == 200
272 data = resp.json()
273 assert data["total"] == 1
274 assert all(n["eventType"] == "watch" for n in data["notifications"])
275
276
277 @pytest.mark.anyio
278 async def test_notifications_json_type_filter_fork(
279 client: AsyncClient,
280 auth_headers: dict[str, str],
281 db_session: AsyncSession,
282 ) -> None:
283 """type=fork returns only fork events."""
284 await _seed(
285 db_session,
286 _make_notif(_TEST_USER_ID, event_type="fork"),
287 _make_notif(_TEST_USER_ID, event_type="mention"),
288 )
289 resp = await client.get(
290 _UI_PATH,
291 params={"format": "json", "type_filter": "fork"},
292 headers=auth_headers,
293 )
294 assert resp.status_code == 200
295 data = resp.json()
296 assert data["total"] == 1
297 assert all(n["eventType"] == "fork" for n in data["notifications"])
298
299
300 @pytest.mark.anyio
301 async def test_notifications_json_unread_only(
302 client: AsyncClient,
303 auth_headers: dict[str, str],
304 db_session: AsyncSession,
305 ) -> None:
306 """unread_only=true excludes already-read notifications."""
307 await _seed(
308 db_session,
309 _make_notif(_TEST_USER_ID, is_read=False),
310 _make_notif(_TEST_USER_ID, is_read=True),
311 _make_notif(_TEST_USER_ID, is_read=False),
312 )
313 resp = await client.get(
314 _UI_PATH,
315 params={"format": "json", "unread_only": "true"},
316 headers=auth_headers,
317 )
318 assert resp.status_code == 200
319 data = resp.json()
320 assert data["total"] == 2
321 assert all(not n["isRead"] for n in data["notifications"])
322 assert data["unreadOnly"] is True
323
324
325 @pytest.mark.anyio
326 async def test_notifications_json_mark_one_read_reflected(
327 client: AsyncClient,
328 auth_headers: dict[str, str],
329 db_session: AsyncSession,
330 ) -> None:
331 """Notification seeded as is_read=True appears with isRead=true in JSON."""
332 await _seed(db_session, _make_notif(_TEST_USER_ID, is_read=True))
333
334 resp = await client.get(
335 _UI_PATH, params={"format": "json"}, headers=auth_headers
336 )
337 assert resp.status_code == 200
338 data = resp.json()
339 assert len(data["notifications"]) == 1
340 assert data["notifications"][0]["isRead"] is True
341
342
343 @pytest.mark.anyio
344 async def test_notifications_json_unread_count_global(
345 client: AsyncClient,
346 auth_headers: dict[str, str],
347 db_session: AsyncSession,
348 ) -> None:
349 """unread_count is the global count, not scoped by the active type filter."""
350 await _seed(
351 db_session,
352 _make_notif(_TEST_USER_ID, event_type="mention", is_read=False),
353 _make_notif(_TEST_USER_ID, event_type="fork", is_read=False),
354 )
355 # Filter to mention only — but unread_count must reflect BOTH unread items.
356 resp = await client.get(
357 _UI_PATH,
358 params={"format": "json", "type_filter": "mention"},
359 headers=auth_headers,
360 )
361 assert resp.status_code == 200
362 data = resp.json()
363 assert data["total"] == 1
364 assert data["unreadCount"] == 2
365
366
367 @pytest.mark.anyio
368 async def test_notifications_json_accept_header(
369 client: AsyncClient,
370 auth_headers: dict[str, str],
371 ) -> None:
372 """Accept: application/json triggers the JSON response path."""
373 headers = {**auth_headers, "Accept": "application/json"}
374 resp = await client.get(_UI_PATH, headers=headers)
375 assert resp.status_code == 200
376 assert "application/json" in resp.headers["content-type"]
377 data = resp.json()
378 assert "notifications" in data
379
380
381 @pytest.mark.anyio
382 async def test_notifications_json_only_own_notifications(
383 client: AsyncClient,
384 auth_headers: dict[str, str],
385 db_session: AsyncSession,
386 ) -> None:
387 """Notifications for other users are not returned in the inbox."""
388 other_user_id = str(uuid.uuid4())
389 await _seed(
390 db_session,
391 _make_notif(_TEST_USER_ID),
392 _make_notif(other_user_id), # belongs to a different user
393 )
394 resp = await client.get(
395 _UI_PATH, params={"format": "json"}, headers=auth_headers
396 )
397 assert resp.status_code == 200
398 data = resp.json()
399 assert data["total"] == 1