gabriel / musehub public
test_musehub_ui_user_profile.py python
482 lines 15.6 KB
cd448303 Initial extraction of MuseHub from maestro monorepo. Gabriel Cardona <gabriel@tellurstori.com> 7d ago
1 """Tests for the enhanced Muse Hub user profile page.
2
3 Covers:
4 - test_profile_page_html_returns_200 — GET /musehub/ui/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 /musehub/ui/users/{username} returns 200 HTML for any username."""
90 await _make_profile(db_session)
91 response = await client.get("/musehub/ui/users/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("/musehub/ui/users/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("/musehub/ui/users/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 includes heatmap rendering JavaScript."""
123 await _make_profile(db_session)
124 response = await client.get("/musehub/ui/users/testuser")
125 assert response.status_code == 200
126 body = response.text
127 assert "renderHeatmap" in body
128 assert "heatmap-cell" in body
129
130
131 @pytest.mark.anyio
132 async def test_profile_page_html_contains_badge_js(
133 client: AsyncClient,
134 db_session: AsyncSession,
135 ) -> None:
136 """HTML includes badge rendering JavaScript."""
137 await _make_profile(db_session)
138 response = await client.get("/musehub/ui/users/testuser")
139 assert response.status_code == 200
140 body = response.text
141 assert "renderBadges" in body
142 assert "badge-card" in body
143
144
145 @pytest.mark.anyio
146 async def test_profile_page_html_contains_pinned_js(
147 client: AsyncClient,
148 db_session: AsyncSession,
149 ) -> None:
150 """HTML includes pinned repos rendering JavaScript."""
151 await _make_profile(db_session)
152 response = await client.get("/musehub/ui/users/testuser")
153 assert response.status_code == 200
154 body = response.text
155 assert "renderPinned" in body
156 assert "pinned-grid" in body
157
158
159 @pytest.mark.anyio
160 async def test_profile_page_html_contains_activity_tab(
161 client: AsyncClient,
162 db_session: AsyncSession,
163 ) -> None:
164 """HTML includes a fourth Activity tab in the tab navigation."""
165 await _make_profile(db_session)
166 response = await client.get("/musehub/ui/users/testuser")
167 assert response.status_code == 200
168 body = response.text
169 assert "Activity" in body
170 assert "loadActivityTab" in body
171
172
173 # ---------------------------------------------------------------------------
174 # JSON path tests
175 # ---------------------------------------------------------------------------
176
177
178 @pytest.mark.anyio
179 async def test_profile_page_json_returns_200(
180 client: AsyncClient,
181 db_session: AsyncSession,
182 ) -> None:
183 """GET /musehub/ui/users/{username}?format=json returns 200 JSON."""
184 await _make_profile(db_session)
185 response = await client.get("/musehub/ui/users/testuser?format=json")
186 assert response.status_code == 200
187 assert "application/json" in response.headers["content-type"]
188
189
190 @pytest.mark.anyio
191 async def test_profile_page_json_unknown_user_404(
192 client: AsyncClient,
193 ) -> None:
194 """?format=json returns 404 for an unknown username."""
195 response = await client.get("/musehub/ui/users/nobody-exists-xyzzy?format=json")
196 assert response.status_code == 404
197
198
199 @pytest.mark.anyio
200 async def test_profile_page_json_heatmap_structure(
201 client: AsyncClient,
202 db_session: AsyncSession,
203 ) -> None:
204 """JSON response contains heatmap with days list and aggregate stats."""
205 await _make_profile(db_session)
206 response = await client.get("/musehub/ui/users/testuser?format=json")
207 assert response.status_code == 200
208 body = response.json()
209
210 assert "heatmap" in body
211 heatmap = body["heatmap"]
212 assert "days" in heatmap
213 assert "totalContributions" in heatmap
214 assert "longestStreak" in heatmap
215 assert "currentStreak" in heatmap
216
217 # Should have ~364 days (52 weeks × 7 days)
218 assert len(heatmap["days"]) >= 360
219
220 # Each day has date, count, intensity
221 first_day = heatmap["days"][0]
222 assert "date" in first_day
223 assert "count" in first_day
224 assert "intensity" in first_day
225 assert first_day["intensity"] in (0, 1, 2, 3)
226
227
228 @pytest.mark.anyio
229 async def test_profile_page_json_badges_structure(
230 client: AsyncClient,
231 db_session: AsyncSession,
232 ) -> None:
233 """JSON response contains exactly 8 badges with required fields."""
234 await _make_profile(db_session)
235 response = await client.get("/musehub/ui/users/testuser?format=json")
236 assert response.status_code == 200
237 body = response.json()
238
239 assert "badges" in body
240 badges = body["badges"]
241 assert len(badges) == 8
242
243 for badge in badges:
244 assert "id" in badge
245 assert "name" in badge
246 assert "description" in badge
247 assert "icon" in badge
248 assert "earned" in badge
249 assert isinstance(badge["earned"], bool)
250
251
252 @pytest.mark.anyio
253 async def test_profile_page_json_pinned_repos(
254 client: AsyncClient,
255 db_session: AsyncSession,
256 ) -> None:
257 """JSON response includes pinned repo cards when pinned_repo_ids are set."""
258 profile = await _make_profile(db_session)
259 repo = await _make_repo(db_session)
260
261 # Pin the repo
262 profile.pinned_repo_ids = [repo.repo_id]
263 db_session.add(profile)
264 await db_session.commit()
265
266 response = await client.get("/musehub/ui/users/testuser?format=json")
267 assert response.status_code == 200
268 body = response.json()
269
270 assert "pinnedRepos" in body
271 pinned = body["pinnedRepos"]
272 assert len(pinned) == 1
273 card = pinned[0]
274 assert card["name"] == "test-beats"
275 assert card["slug"] == "test-beats"
276 assert "starCount" in card
277 assert "forkCount" in card
278
279
280 @pytest.mark.anyio
281 async def test_profile_page_json_activity_empty(
282 client: AsyncClient,
283 db_session: AsyncSession,
284 ) -> None:
285 """JSON response returns empty activity list for a new user with no events."""
286 await _make_profile(db_session)
287 response = await client.get("/musehub/ui/users/testuser?format=json")
288 assert response.status_code == 200
289 body = response.json()
290
291 assert "activity" in body
292 assert isinstance(body["activity"], list)
293 assert body["totalEvents"] == 0
294 assert body["page"] == 1
295 assert body["perPage"] == 20
296
297
298 @pytest.mark.anyio
299 async def test_profile_page_json_activity_filter(
300 client: AsyncClient,
301 db_session: AsyncSession,
302 ) -> None:
303 """?tab=commits filters activity response to commits-only event types."""
304 await _make_profile(db_session)
305 response = await client.get("/musehub/ui/users/testuser?format=json&tab=commits")
306 assert response.status_code == 200
307 body = response.json()
308 assert body["activityFilter"] == "commits"
309
310
311 @pytest.mark.anyio
312 async def test_profile_page_json_badge_first_commit_earned(
313 client: AsyncClient,
314 db_session: AsyncSession,
315 ) -> None:
316 """first_commit badge is earned after the user has at least one commit."""
317 from datetime import datetime, timezone
318
319 profile = await _make_profile(db_session)
320 repo = await _make_repo(db_session)
321
322 # Seed one commit owned by this user's repo
323 commit = MusehubCommit(
324 commit_id="abc123def456abc123def456abc123def456abc1",
325 repo_id=repo.repo_id,
326 branch="main",
327 parent_ids=[],
328 message="initial commit",
329 author="testuser",
330 timestamp=datetime.now(tz=timezone.utc),
331 )
332 db_session.add(commit)
333 await db_session.commit()
334
335 response = await client.get("/musehub/ui/users/testuser?format=json")
336 assert response.status_code == 200
337 body = response.json()
338
339 badges = {b["id"]: b for b in body["badges"]}
340 assert "first_commit" in badges
341 assert badges["first_commit"]["earned"] is True
342
343
344 @pytest.mark.anyio
345 async def test_profile_page_json_camel_case_keys(
346 client: AsyncClient,
347 db_session: AsyncSession,
348 ) -> None:
349 """JSON response uses camelCase keys throughout (no snake_case at top level)."""
350 await _make_profile(db_session)
351 response = await client.get("/musehub/ui/users/testuser?format=json")
352 assert response.status_code == 200
353 body = response.json()
354
355 # Top-level camelCase keys
356 assert "avatarUrl" in body
357 assert "totalEvents" in body
358 assert "activityFilter" in body
359 assert "pinnedRepos" in body
360
361 # No snake_case variants
362 assert "avatar_url" not in body
363 assert "total_events" not in body
364 assert "pinned_repos" not in body
365
366
367 # ---------------------------------------------------------------------------
368 # Issue #448 — rich artist profiles with CC attribution fields
369 # ---------------------------------------------------------------------------
370
371
372 @pytest.mark.anyio
373 async def test_profile_model_rich_fields_stored_and_retrieved(
374 db_session: AsyncSession,
375 ) -> None:
376 """MusehubProfile stores and retrieves all CC-attribution fields added.
377
378 Regression: before this fix, display_name / location / website_url /
379 twitter_handle / is_verified / cc_license did not exist on the model or
380 schema; saving them would silently discard the data.
381 """
382 profile = MusehubProfile(
383 user_id="user-test-cc-001",
384 username="kevin_macleod_test",
385 display_name="Kevin MacLeod",
386 bio="Prolific composer. Every genre. Royalty-free forever.",
387 location="Sandpoint, Idaho",
388 website_url="https://incompetech.com",
389 twitter_handle="kmacleod",
390 is_verified=True,
391 cc_license="CC BY 4.0",
392 pinned_repo_ids=[],
393 )
394 db_session.add(profile)
395 await db_session.commit()
396 await db_session.refresh(profile)
397
398 assert profile.display_name == "Kevin MacLeod"
399 assert profile.location == "Sandpoint, Idaho"
400 assert profile.website_url == "https://incompetech.com"
401 assert profile.twitter_handle == "kmacleod"
402 assert profile.is_verified is True
403 assert profile.cc_license == "CC BY 4.0"
404
405
406 @pytest.mark.anyio
407 async def test_profile_model_verified_defaults_false(
408 db_session: AsyncSession,
409 ) -> None:
410 """is_verified defaults to False for community users — no accidental verification."""
411 profile = MusehubProfile(
412 user_id="user-test-community-002",
413 username="community_user_test",
414 bio="Just a regular community user.",
415 pinned_repo_ids=[],
416 )
417 db_session.add(profile)
418 await db_session.commit()
419 await db_session.refresh(profile)
420
421 assert profile.is_verified is False
422 assert profile.cc_license is None
423 assert profile.display_name is None
424 assert profile.location is None
425 assert profile.twitter_handle is None
426
427
428 @pytest.mark.anyio
429 async def test_profile_model_public_domain_artist(
430 db_session: AsyncSession,
431 ) -> None:
432 """Public Domain composers get is_verified=True and cc_license='Public Domain'."""
433 profile = MusehubProfile(
434 user_id="user-test-bach-003",
435 username="bach_test",
436 display_name="Johann Sebastian Bach",
437 bio="Baroque composer. 48 preludes, 48 fugues.",
438 location="Leipzig, Saxony (1723-1750)",
439 website_url="https://www.bach-digital.de",
440 twitter_handle=None,
441 is_verified=True,
442 cc_license="Public Domain",
443 pinned_repo_ids=[],
444 )
445 db_session.add(profile)
446 await db_session.commit()
447 await db_session.refresh(profile)
448
449 assert profile.is_verified is True
450 assert profile.cc_license == "Public Domain"
451 assert profile.twitter_handle is None
452
453
454 @pytest.mark.anyio
455 async def test_profile_page_json_includes_verified_and_license(
456 client: AsyncClient,
457 db_session: AsyncSession,
458 ) -> None:
459 """Profile JSON endpoint exposes isVerified and ccLicense fields for CC artists."""
460 profile = MusehubProfile(
461 user_id="user-test-cc-api-004",
462 username="kai_engel_test",
463 display_name="Kai Engel",
464 bio="Ambient architect. Long-form textures.",
465 location="Germany",
466 website_url="https://freemusicarchive.org/music/Kai_Engel",
467 twitter_handle=None,
468 is_verified=True,
469 cc_license="CC BY 4.0",
470 pinned_repo_ids=[],
471 )
472 db_session.add(profile)
473 await db_session.commit()
474
475 response = await client.get("/musehub/ui/users/kai_engel_test?format=json")
476 assert response.status_code == 200
477 body = response.json()
478
479 # The profile card must surface verification status and license so the
480 # frontend can render the CC badge without a secondary API call.
481 assert body.get("isVerified") is True
482 assert body.get("ccLicense") == "CC BY 4.0"