gabriel / musehub public
test_musehub_ui_user_profile.py python
486 lines 15.8 KB
c0f0b481 release: merge dev → main (#5) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """Tests for the enhanced MuseHub user profile page.
2
3 Covers:
4 - test_profile_page_html_returns_200 — GET /users/{username} returns 200 HTML
5 - test_profile_page_no_auth_required — accessible without JWT
6 - test_profile_page_unknown_user_still_renders — unknown username still returns 200 HTML shell
7 - test_profile_page_html_contains_heatmap_js — page includes heatmap rendering JavaScript
8 - test_profile_page_html_contains_badge_js — page includes badge rendering JavaScript
9 - test_profile_page_html_contains_pinned_js — page includes pinned repos JavaScript
10 - test_profile_page_html_contains_activity_tab — page includes Activity tab
11 - test_profile_page_json_returns_200 — ?format=json returns 200 JSON
12 - test_profile_page_json_unknown_user_404 — ?format=json returns 404 for unknown user
13 - test_profile_page_json_heatmap_structure — JSON response has heatmap with days/stats
14 - test_profile_page_json_badges_structure — JSON response has 8 badges with expected fields
15 - test_profile_page_json_pinned_repos — JSON response includes pinned repo cards
16 - test_profile_page_json_activity_empty — JSON response returns empty activity for new user
17 - test_profile_page_json_activity_filter — ?tab=commits filters activity to commits only
18 - test_profile_page_json_badge_first_commit_earned — first_commit badge earned after seeding a commit
19 - test_profile_page_json_camel_case_keys — JSON keys are camelCase
20 """
21 from __future__ import annotations
22
23 import pytest
24 from httpx import AsyncClient
25 from sqlalchemy.ext.asyncio import AsyncSession
26
27 from musehub.db.musehub_models import MusehubCommit, MusehubProfile, MusehubRepo
28
29
30 # ---------------------------------------------------------------------------
31 # Helpers
32 # ---------------------------------------------------------------------------
33
34
35 async def _make_profile(
36 db: AsyncSession,
37 *,
38 username: str = "testuser",
39 user_id: str = "user-profile-test-001",
40 bio: str | None = "Test bio",
41 ) -> MusehubProfile:
42 """Seed a minimal MusehubProfile."""
43 profile = MusehubProfile(
44 user_id=user_id,
45 username=username,
46 bio=bio,
47 avatar_url=None,
48 pinned_repo_ids=[],
49 )
50 db.add(profile)
51 await db.commit()
52 await db.refresh(profile)
53 return profile
54
55
56 async def _make_repo(
57 db: AsyncSession,
58 *,
59 owner_user_id: str = "user-profile-test-001",
60 owner: str = "testuser",
61 name: str = "test-beats",
62 slug: str = "test-beats",
63 visibility: str = "public",
64 ) -> MusehubRepo:
65 """Seed a minimal MusehubRepo."""
66 repo = MusehubRepo(
67 name=name,
68 owner=owner,
69 slug=slug,
70 visibility=visibility,
71 owner_user_id=owner_user_id,
72 )
73 db.add(repo)
74 await db.commit()
75 await db.refresh(repo)
76 return repo
77
78
79 # ---------------------------------------------------------------------------
80 # HTML path tests
81 # ---------------------------------------------------------------------------
82
83
84 @pytest.mark.anyio
85 async def test_profile_page_html_returns_200(
86 client: AsyncClient,
87 db_session: AsyncSession,
88 ) -> None:
89 """GET /users/{username} returns 200 HTML for any username."""
90 await _make_profile(db_session)
91 response = await client.get("/testuser")
92 assert response.status_code == 200
93 assert "text/html" in response.headers["content-type"]
94
95
96 @pytest.mark.anyio
97 async def test_profile_page_no_auth_required(
98 client: AsyncClient,
99 db_session: AsyncSession,
100 ) -> None:
101 """Profile page is publicly accessible without a JWT token."""
102 await _make_profile(db_session)
103 response = await client.get("/testuser")
104 assert response.status_code == 200
105
106
107 @pytest.mark.anyio
108 async def test_profile_page_unknown_user_still_renders(
109 client: AsyncClient,
110 ) -> None:
111 """HTML shell renders even for unknown users — data fetched client-side."""
112 response = await client.get("/nobody-exists-xyzzy")
113 assert response.status_code == 200
114 assert "text/html" in response.headers["content-type"]
115
116
117 @pytest.mark.anyio
118 async def test_profile_page_html_contains_heatmap_js(
119 client: AsyncClient,
120 db_session: AsyncSession,
121 ) -> None:
122 """HTML dispatches the user-profile TypeScript module (heatmap rendered client-side)."""
123 await _make_profile(db_session)
124 response = await client.get("/testuser")
125 assert response.status_code == 200
126 body = response.text
127 # renderHeatmap moved to app.js; page dispatch JSON confirms module will run
128 assert '"page": "user-profile"' in body
129 assert '"username": "testuser"' in body
130
131
132 @pytest.mark.anyio
133 async def test_profile_page_html_contains_badge_js(
134 client: AsyncClient,
135 db_session: AsyncSession,
136 ) -> None:
137 """HTML dispatches user-profile module which renders badges client-side."""
138 await _make_profile(db_session)
139 response = await client.get("/testuser")
140 assert response.status_code == 200
141 body = response.text
142 # renderBadges moved to app.js; verify page dispatch and profile container
143 assert '"page": "user-profile"' in body
144 assert "profile-container" in body or "content" in body
145
146
147 @pytest.mark.anyio
148 async def test_profile_page_html_contains_pinned_js(
149 client: AsyncClient,
150 db_session: AsyncSession,
151 ) -> None:
152 """HTML dispatches user-profile module which renders pinned repos client-side."""
153 await _make_profile(db_session)
154 response = await client.get("/testuser")
155 assert response.status_code == 200
156 body = response.text
157 # renderPinned moved to app.js; verify page dispatch JSON
158 assert '"page": "user-profile"' in body
159 assert "testuser" in body
160
161
162 @pytest.mark.anyio
163 async def test_profile_page_html_contains_activity_tab(
164 client: AsyncClient,
165 db_session: AsyncSession,
166 ) -> None:
167 """HTML renders the profile page; Activity tab is driven by the TypeScript module."""
168 await _make_profile(db_session)
169 response = await client.get("/testuser")
170 assert response.status_code == 200
171 body = response.text
172 # loadActivityTab moved to app.js; verify the page dispatches user-profile module
173 assert "Activity" in body
174 assert '"page": "user-profile"' in body
175
176
177 # ---------------------------------------------------------------------------
178 # JSON path tests
179 # ---------------------------------------------------------------------------
180
181
182 @pytest.mark.anyio
183 async def test_profile_page_json_returns_200(
184 client: AsyncClient,
185 db_session: AsyncSession,
186 ) -> None:
187 """GET /users/{username}?format=json returns 200 JSON."""
188 await _make_profile(db_session)
189 response = await client.get("/testuser?format=json")
190 assert response.status_code == 200
191 assert "application/json" in response.headers["content-type"]
192
193
194 @pytest.mark.anyio
195 async def test_profile_page_json_unknown_user_404(
196 client: AsyncClient,
197 ) -> None:
198 """?format=json returns 404 for an unknown username."""
199 response = await client.get("/nobody-exists-xyzzy?format=json")
200 assert response.status_code == 404
201
202
203 @pytest.mark.anyio
204 async def test_profile_page_json_heatmap_structure(
205 client: AsyncClient,
206 db_session: AsyncSession,
207 ) -> None:
208 """JSON response contains heatmap with days list and aggregate stats."""
209 await _make_profile(db_session)
210 response = await client.get("/testuser?format=json")
211 assert response.status_code == 200
212 body = response.json()
213
214 assert "heatmap" in body
215 heatmap = body["heatmap"]
216 assert "days" in heatmap
217 assert "totalContributions" in heatmap
218 assert "longestStreak" in heatmap
219 assert "currentStreak" in heatmap
220
221 # Should have ~364 days (52 weeks × 7 days)
222 assert len(heatmap["days"]) >= 360
223
224 # Each day has date, count, intensity
225 first_day = heatmap["days"][0]
226 assert "date" in first_day
227 assert "count" in first_day
228 assert "intensity" in first_day
229 assert first_day["intensity"] in (0, 1, 2, 3)
230
231
232 @pytest.mark.anyio
233 async def test_profile_page_json_badges_structure(
234 client: AsyncClient,
235 db_session: AsyncSession,
236 ) -> None:
237 """JSON response contains exactly 8 badges with required fields."""
238 await _make_profile(db_session)
239 response = await client.get("/testuser?format=json")
240 assert response.status_code == 200
241 body = response.json()
242
243 assert "badges" in body
244 badges = body["badges"]
245 assert len(badges) == 8
246
247 for badge in badges:
248 assert "id" in badge
249 assert "name" in badge
250 assert "description" in badge
251 assert "icon" in badge
252 assert "earned" in badge
253 assert isinstance(badge["earned"], bool)
254
255
256 @pytest.mark.anyio
257 async def test_profile_page_json_pinned_repos(
258 client: AsyncClient,
259 db_session: AsyncSession,
260 ) -> None:
261 """JSON response includes pinned repo cards when pinned_repo_ids are set."""
262 profile = await _make_profile(db_session)
263 repo = await _make_repo(db_session)
264
265 # Pin the repo
266 profile.pinned_repo_ids = [repo.repo_id]
267 db_session.add(profile)
268 await db_session.commit()
269
270 response = await client.get("/testuser?format=json")
271 assert response.status_code == 200
272 body = response.json()
273
274 assert "pinnedRepos" in body
275 pinned = body["pinnedRepos"]
276 assert len(pinned) == 1
277 card = pinned[0]
278 assert card["name"] == "test-beats"
279 assert card["slug"] == "test-beats"
280 assert "starCount" in card
281 assert "forkCount" in card
282
283
284 @pytest.mark.anyio
285 async def test_profile_page_json_activity_empty(
286 client: AsyncClient,
287 db_session: AsyncSession,
288 ) -> None:
289 """JSON response returns empty activity list for a new user with no events."""
290 await _make_profile(db_session)
291 response = await client.get("/testuser?format=json")
292 assert response.status_code == 200
293 body = response.json()
294
295 assert "activity" in body
296 assert isinstance(body["activity"], list)
297 assert body["totalEvents"] == 0
298 assert body["page"] == 1
299 assert body["perPage"] == 20
300
301
302 @pytest.mark.anyio
303 async def test_profile_page_json_activity_filter(
304 client: AsyncClient,
305 db_session: AsyncSession,
306 ) -> None:
307 """?tab=commits filters activity response to commits-only event types."""
308 await _make_profile(db_session)
309 response = await client.get("/testuser?format=json&tab=commits")
310 assert response.status_code == 200
311 body = response.json()
312 assert body["activityFilter"] == "commits"
313
314
315 @pytest.mark.anyio
316 async def test_profile_page_json_badge_first_commit_earned(
317 client: AsyncClient,
318 db_session: AsyncSession,
319 ) -> None:
320 """first_commit badge is earned after the user has at least one commit."""
321 from datetime import datetime, timezone
322
323 profile = await _make_profile(db_session)
324 repo = await _make_repo(db_session)
325
326 # Seed one commit owned by this user's repo
327 commit = MusehubCommit(
328 commit_id="abc123def456abc123def456abc123def456abc1",
329 repo_id=repo.repo_id,
330 branch="main",
331 parent_ids=[],
332 message="initial commit",
333 author="testuser",
334 timestamp=datetime.now(tz=timezone.utc),
335 )
336 db_session.add(commit)
337 await db_session.commit()
338
339 response = await client.get("/testuser?format=json")
340 assert response.status_code == 200
341 body = response.json()
342
343 badges = {b["id"]: b for b in body["badges"]}
344 assert "first_commit" in badges
345 assert badges["first_commit"]["earned"] is True
346
347
348 @pytest.mark.anyio
349 async def test_profile_page_json_camel_case_keys(
350 client: AsyncClient,
351 db_session: AsyncSession,
352 ) -> None:
353 """JSON response uses camelCase keys throughout (no snake_case at top level)."""
354 await _make_profile(db_session)
355 response = await client.get("/testuser?format=json")
356 assert response.status_code == 200
357 body = response.json()
358
359 # Top-level camelCase keys
360 assert "avatarUrl" in body
361 assert "totalEvents" in body
362 assert "activityFilter" in body
363 assert "pinnedRepos" in body
364
365 # No snake_case variants
366 assert "avatar_url" not in body
367 assert "total_events" not in body
368 assert "pinned_repos" not in body
369
370
371 # ---------------------------------------------------------------------------
372 # Issue #448 — rich artist profiles with CC attribution fields
373 # ---------------------------------------------------------------------------
374
375
376 @pytest.mark.anyio
377 async def test_profile_model_rich_fields_stored_and_retrieved(
378 db_session: AsyncSession,
379 ) -> None:
380 """MusehubProfile stores and retrieves all CC-attribution fields added.
381
382 Regression: before this fix, display_name / location / website_url /
383 twitter_handle / is_verified / cc_license did not exist on the model or
384 schema; saving them would silently discard the data.
385 """
386 profile = MusehubProfile(
387 user_id="user-test-cc-001",
388 username="kevin_macleod_test",
389 display_name="Kevin MacLeod",
390 bio="Prolific composer. Every genre. Royalty-free forever.",
391 location="Sandpoint, Idaho",
392 website_url="https://incompetech.com",
393 twitter_handle="kmacleod",
394 is_verified=True,
395 cc_license="CC BY 4.0",
396 pinned_repo_ids=[],
397 )
398 db_session.add(profile)
399 await db_session.commit()
400 await db_session.refresh(profile)
401
402 assert profile.display_name == "Kevin MacLeod"
403 assert profile.location == "Sandpoint, Idaho"
404 assert profile.website_url == "https://incompetech.com"
405 assert profile.twitter_handle == "kmacleod"
406 assert profile.is_verified is True
407 assert profile.cc_license == "CC BY 4.0"
408
409
410 @pytest.mark.anyio
411 async def test_profile_model_verified_defaults_false(
412 db_session: AsyncSession,
413 ) -> None:
414 """is_verified defaults to False for community users — no accidental verification."""
415 profile = MusehubProfile(
416 user_id="user-test-community-002",
417 username="community_user_test",
418 bio="Just a regular community user.",
419 pinned_repo_ids=[],
420 )
421 db_session.add(profile)
422 await db_session.commit()
423 await db_session.refresh(profile)
424
425 assert profile.is_verified is False
426 assert profile.cc_license is None
427 assert profile.display_name is None
428 assert profile.location is None
429 assert profile.twitter_handle is None
430
431
432 @pytest.mark.anyio
433 async def test_profile_model_public_domain_artist(
434 db_session: AsyncSession,
435 ) -> None:
436 """Public Domain composers get is_verified=True and cc_license='Public Domain'."""
437 profile = MusehubProfile(
438 user_id="user-test-bach-003",
439 username="bach_test",
440 display_name="Johann Sebastian Bach",
441 bio="Baroque composer. 48 preludes, 48 fugues.",
442 location="Leipzig, Saxony (1723-1750)",
443 website_url="https://www.bach-digital.de",
444 twitter_handle=None,
445 is_verified=True,
446 cc_license="Public Domain",
447 pinned_repo_ids=[],
448 )
449 db_session.add(profile)
450 await db_session.commit()
451 await db_session.refresh(profile)
452
453 assert profile.is_verified is True
454 assert profile.cc_license == "Public Domain"
455 assert profile.twitter_handle is None
456
457
458 @pytest.mark.anyio
459 async def test_profile_page_json_includes_verified_and_license(
460 client: AsyncClient,
461 db_session: AsyncSession,
462 ) -> None:
463 """Profile JSON endpoint exposes isVerified and ccLicense fields for CC artists."""
464 profile = MusehubProfile(
465 user_id="user-test-cc-api-004",
466 username="kai_engel_test",
467 display_name="Kai Engel",
468 bio="Ambient architect. Long-form textures.",
469 location="Germany",
470 website_url="https://freemusicarchive.org/music/Kai_Engel",
471 twitter_handle=None,
472 is_verified=True,
473 cc_license="CC BY 4.0",
474 pinned_repo_ids=[],
475 )
476 db_session.add(profile)
477 await db_session.commit()
478
479 response = await client.get("/kai_engel_test?format=json")
480 assert response.status_code == 200
481 body = response.json()
482
483 # The profile card must surface verification status and license so the
484 # frontend can render the CC badge without a secondary API call.
485 assert body.get("isVerified") is True
486 assert body.get("ccLicense") == "CC BY 4.0"