gabriel / musehub public
test_musehub_ui_user_profile.py python
665 lines 22.1 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 days 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 authentication
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, MusehubIdentity, 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 ) -> MusehubIdentity:
42 """Seed a minimal MusehubIdentity."""
43 profile = MusehubIdentity(
44 identity_id=user_id,
45 handle=username,
46 identity_type="human",
47 bio=bio,
48 avatar_url=None,
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 from datetime import datetime, timezone
67 from musehub.core.genesis import compute_repo_id
68 repo_id = compute_repo_id(owner_user_id, slug, "code", datetime.now(timezone.utc).isoformat())
69 repo = MusehubRepo(
70 repo_id=repo_id,
71 name=name,
72 owner=owner,
73 slug=slug,
74 visibility=visibility,
75 owner_user_id=owner_user_id,
76 )
77 db.add(repo)
78 await db.commit()
79 await db.refresh(repo)
80 return repo
81
82
83 # ---------------------------------------------------------------------------
84 # HTML path tests
85 # ---------------------------------------------------------------------------
86
87
88 async def test_profile_page_html_returns_200(
89 client: AsyncClient,
90 db_session: AsyncSession,
91 ) -> None:
92 """GET /users/{username} returns 200 HTML for any username."""
93 await _make_profile(db_session)
94 response = await client.get("/testuser")
95 assert response.status_code == 200
96 assert "text/html" in response.headers["content-type"]
97
98
99 async def test_profile_page_no_auth_required(
100 client: AsyncClient,
101 db_session: AsyncSession,
102 ) -> None:
103 """Profile page is publicly accessible without authentication."""
104 await _make_profile(db_session)
105 response = await client.get("/testuser")
106 assert response.status_code == 200
107
108
109 async def test_profile_page_unknown_user_still_renders(
110 client: AsyncClient,
111 ) -> None:
112 """HTML shell renders even for unknown users — data fetched client-side."""
113 response = await client.get("/nobody-exists-xyzzy")
114 assert response.status_code == 200
115 assert "text/html" in response.headers["content-type"]
116
117
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 async def test_profile_page_html_contains_badge_js(
133 client: AsyncClient,
134 db_session: AsyncSession,
135 ) -> None:
136 """HTML dispatches user-profile module which renders badges client-side."""
137 await _make_profile(db_session)
138 response = await client.get("/testuser")
139 assert response.status_code == 200
140 body = response.text
141 # renderBadges moved to app.js; verify page dispatch and profile container
142 assert '"page": "user-profile"' in body
143 assert "profile-container" in body or "content" in body
144
145
146 async def test_profile_page_html_contains_pinned_js(
147 client: AsyncClient,
148 db_session: AsyncSession,
149 ) -> None:
150 """HTML dispatches user-profile module which renders pinned repos client-side."""
151 await _make_profile(db_session)
152 response = await client.get("/testuser")
153 assert response.status_code == 200
154 body = response.text
155 # renderPinned moved to app.js; verify page dispatch JSON
156 assert '"page": "user-profile"' in body
157 assert "testuser" in body
158
159
160 async def test_profile_page_html_contains_activity_tab(
161 client: AsyncClient,
162 db_session: AsyncSession,
163 ) -> None:
164 """HTML renders the profile page with user-profile page dispatch (activity driven by JS)."""
165 await _make_profile(db_session)
166 response = await client.get("/testuser")
167 assert response.status_code == 200
168 body = response.text
169 # Reimagined template: activity sections are data-driven; module dispatch always present
170 assert '"page": "user-profile"' in body
171 assert "testuser" in body
172
173
174 # ---------------------------------------------------------------------------
175 # JSON path tests
176 # ---------------------------------------------------------------------------
177
178
179 async def test_profile_page_json_returns_200(
180 client: AsyncClient,
181 db_session: AsyncSession,
182 ) -> None:
183 """GET /users/{username}?format=json returns 200 JSON."""
184 await _make_profile(db_session)
185 response = await client.get("/testuser?format=json")
186 assert response.status_code == 200
187 assert "application/json" in response.headers["content-type"]
188
189
190 async def test_profile_page_json_unknown_user_404(
191 client: AsyncClient,
192 ) -> None:
193 """?format=json returns 404 for an unknown username."""
194 response = await client.get("/nobody-exists-xyzzy?format=json")
195 assert response.status_code == 404
196
197
198 async def test_profile_page_json_heatmap_structure(
199 client: AsyncClient,
200 db_session: AsyncSession,
201 ) -> None:
202 """JSON response contains heatmap with days list and aggregate stats."""
203 await _make_profile(db_session)
204 response = await client.get("/testuser?format=json")
205 assert response.status_code == 200
206 body = response.json()
207
208 assert "heatmap" in body
209 heatmap = body["heatmap"]
210 assert "days" in heatmap
211 assert "totalContributions" in heatmap
212 assert "longestStreak" in heatmap
213 assert "currentStreak" in heatmap
214
215 # Should have ~364 days (52 weeks × 7 days)
216 assert len(heatmap["days"]) >= 360
217
218 # Each day has date, count, intensity
219 first_day = heatmap["days"][0]
220 assert "date" in first_day
221 assert "count" in first_day
222 assert "intensity" in first_day
223 assert first_day["intensity"] in (0, 1, 2, 3)
224
225
226 async def test_profile_page_json_badges_structure(
227 client: AsyncClient,
228 db_session: AsyncSession,
229 ) -> None:
230 """JSON response contains exactly 8 badges with required fields."""
231 await _make_profile(db_session)
232 response = await client.get("/testuser?format=json")
233 assert response.status_code == 200
234 body = response.json()
235
236 assert "badges" in body
237 badges = body["badges"]
238 assert len(badges) == 8
239
240 for badge in badges:
241 assert "id" in badge
242 assert "name" in badge
243 assert "description" in badge
244 assert "icon" in badge
245 assert "earned" in badge
246 assert isinstance(badge["earned"], bool)
247
248
249 async def test_profile_page_json_pinned_repos(
250 client: AsyncClient,
251 db_session: AsyncSession,
252 ) -> None:
253 """JSON response includes pinned repo cards when pinned_repo_ids are set."""
254 profile = await _make_profile(db_session)
255 repo = await _make_repo(db_session)
256
257 # Pin the repo
258 profile.pinned_repo_ids = [repo.repo_id]
259 db_session.add(profile)
260 await db_session.commit()
261
262 response = await client.get("/testuser?format=json")
263 assert response.status_code == 200
264 body = response.json()
265
266 assert "pinnedRepos" in body
267 pinned = body["pinnedRepos"]
268 assert len(pinned) == 1
269 card = pinned[0]
270 assert card["name"] == "test-beats"
271 assert card["slug"] == "test-beats"
272 assert "forkCount" in card
273
274
275 async def test_profile_page_json_activity_empty(
276 client: AsyncClient,
277 db_session: AsyncSession,
278 ) -> None:
279 """JSON response returns empty activity list for a new user with no events."""
280 await _make_profile(db_session)
281 response = await client.get("/testuser?format=json")
282 assert response.status_code == 200
283 body = response.json()
284
285 assert "activity" in body
286 assert isinstance(body["activity"], list)
287 assert body["totalEvents"] == 0
288 assert body["page"] == 1
289 assert body["perPage"] == 20
290
291
292 async def test_profile_page_json_activity_filter(
293 client: AsyncClient,
294 db_session: AsyncSession,
295 ) -> None:
296 """?tab=commits filters activity response to commits-only event types."""
297 await _make_profile(db_session)
298 response = await client.get("/testuser?format=json&tab=commits")
299 assert response.status_code == 200
300 body = response.json()
301 assert body["activityFilter"] == "commits"
302
303
304 async def test_profile_page_json_badge_first_commit_earned(
305 client: AsyncClient,
306 db_session: AsyncSession,
307 ) -> None:
308 """first_commit badge is earned after the user has at least one commit."""
309 from datetime import datetime, timezone
310
311 profile = await _make_profile(db_session)
312 repo = await _make_repo(db_session)
313
314 # Seed one commit owned by this user's repo
315 commit = MusehubCommit(
316 commit_id="abc123def456abc123def456abc123def456abc1",
317 repo_id=repo.repo_id,
318 branch="main",
319 parent_ids=[],
320 message="initial commit",
321 author="testuser",
322 timestamp=datetime.now(tz=timezone.utc),
323 )
324 db_session.add(commit)
325 await db_session.commit()
326
327 response = await client.get("/testuser?format=json")
328 assert response.status_code == 200
329 body = response.json()
330
331 badges = {b["id"]: b for b in body["badges"]}
332 assert "first_commit" in badges
333 assert badges["first_commit"]["earned"] is True
334
335
336 async def test_profile_page_json_camel_case_keys(
337 client: AsyncClient,
338 db_session: AsyncSession,
339 ) -> None:
340 """JSON response uses camelCase keys throughout (no snake_case at top level)."""
341 await _make_profile(db_session)
342 response = await client.get("/testuser?format=json")
343 assert response.status_code == 200
344 body = response.json()
345
346 # Top-level camelCase keys
347 assert "avatarUrl" in body
348 assert "totalEvents" in body
349 assert "activityFilter" in body
350 assert "pinnedRepos" in body
351
352 # No snake_case variants
353 assert "avatar_url" not in body
354 assert "total_events" not in body
355 assert "pinned_repos" not in body
356
357
358 # ---------------------------------------------------------------------------
359 # Issue #448 — rich artist profiles with CC attribution fields
360 # ---------------------------------------------------------------------------
361
362
363 async def test_profile_model_rich_fields_stored_and_retrieved(
364 db_session: AsyncSession,
365 ) -> None:
366 """MusehubIdentity stores and retrieves all CC-attribution fields added.
367
368 Regression: before this fix, display_name / location / website_url /
369 social_url / is_verified / cc_license did not exist on the model or
370 schema; saving them would silently discard the data.
371 """
372 profile = MusehubIdentity(
373 identity_id="user-test-cc-001",
374 handle="kevin_macleod_test",
375 display_name="Kevin MacLeod",
376 bio="Prolific composer. Every genre. Royalty-free forever.",
377 location="Sandpoint, Idaho",
378 website_url="https://incompetech.com",
379 social_url="kmacleod",
380 is_verified=True,
381 cc_license="CC BY 4.0",
382 )
383 db_session.add(profile)
384 await db_session.commit()
385 await db_session.refresh(profile)
386
387 assert profile.display_name == "Kevin MacLeod"
388 assert profile.location == "Sandpoint, Idaho"
389 assert profile.website_url == "https://incompetech.com"
390 assert profile.social_url == "kmacleod"
391 assert profile.is_verified is True
392 assert profile.cc_license == "CC BY 4.0"
393
394
395 async def test_profile_model_verified_defaults_false(
396 db_session: AsyncSession,
397 ) -> None:
398 """is_verified defaults to False for community users — no accidental verification."""
399 profile = MusehubIdentity(
400 identity_id="user-test-community-002",
401 handle="community_user_test",
402 bio="Just a regular community user.",
403 )
404 db_session.add(profile)
405 await db_session.commit()
406 await db_session.refresh(profile)
407
408 assert profile.is_verified is False
409 assert profile.cc_license is None
410 assert profile.display_name is None
411 assert profile.location is None
412 assert profile.social_url is None
413
414
415 async def test_profile_model_public_domain_artist(
416 db_session: AsyncSession,
417 ) -> None:
418 """Public Domain composers get is_verified=True and cc_license='Public Domain'."""
419 profile = MusehubIdentity(
420 identity_id="user-test-bach-003",
421 handle="bach_test",
422 display_name="Johann Sebastian Bach",
423 bio="Baroque composer. 48 preludes, 48 fugues.",
424 location="Leipzig, Saxony (1723-1750)",
425 website_url="https://www.bach-digital.de",
426 social_url=None,
427 is_verified=True,
428 cc_license="Public Domain",
429 )
430 db_session.add(profile)
431 await db_session.commit()
432 await db_session.refresh(profile)
433
434 assert profile.is_verified is True
435 assert profile.cc_license == "Public Domain"
436 assert profile.social_url is None
437
438
439 async def test_profile_page_json_includes_verified_and_license(
440 client: AsyncClient,
441 db_session: AsyncSession,
442 ) -> None:
443 """Profile JSON endpoint exposes isVerified and ccLicense fields for CC artists."""
444 profile = MusehubIdentity(
445 identity_id="user-test-cc-api-004",
446 handle="kai_engel_test",
447 display_name="Kai Engel",
448 bio="Ambient architect. Long-form textures.",
449 location="Germany",
450 website_url="https://freemusicarchive.org/music/Kai_Engel",
451 social_url=None,
452 is_verified=True,
453 cc_license="CC BY 4.0",
454 )
455 db_session.add(profile)
456 await db_session.commit()
457
458 response = await client.get("/kai_engel_test?format=json")
459 assert response.status_code == 200
460 body = response.json()
461
462 # The profile card must surface verification status and license so the
463 # frontend can render the CC badge without a secondary API call.
464 assert body.get("isVerified") is True
465 assert body.get("ccLicense") == "CC BY 4.0"
466
467
468 # ===========================================================================
469 # Profile Header Reimagination — TDD tests (Issue #1)
470 # Phase 1: repos pipeline (owner query)
471 # Phase 2: bio field
472 # Phase 3: AVAX address
473 # Phase 4: repo chip domain icons
474 # ===========================================================================
475
476 # ---------------------------------------------------------------------------
477 # Helpers shared by header tests
478 # ---------------------------------------------------------------------------
479
480 async def _make_identity_with_repos(
481 db: AsyncSession,
482 *,
483 handle: str = "herouser",
484 bio: str | None = None,
485 avax_address: str | None = None,
486 repo_slugs: list[str] | None = None,
487 ) -> MusehubIdentity:
488 """Seed a MusehubIdentity + repos where owner==handle (realistic data shape).
489
490 owner_user_id is set to the handle string — matching production data where
491 repos were created before the identity_id was stable. The repo pipeline fix
492 must resolve repos via owner==handle, not owner_user_id==identity_id.
493 """
494 from datetime import datetime, timezone
495 from musehub.core.genesis import compute_repo_id
496
497 now_iso = datetime.now(timezone.utc).isoformat()
498 identity_id = f"sha256:{handle.ljust(64, '0')}"[:71]
499 profile = MusehubIdentity(
500 identity_id=identity_id,
501 handle=handle,
502 identity_type="human",
503 bio=bio,
504 avax_address=avax_address,
505 avatar_url=None,
506 )
507 db.add(profile)
508 await db.flush()
509
510 for slug in (repo_slugs or []):
511 repo_id = compute_repo_id(identity_id, slug, "code", now_iso)
512 repo = MusehubRepo(
513 repo_id=repo_id,
514 name=slug,
515 owner=handle,
516 slug=slug,
517 visibility="public",
518 # owner_user_id stores the handle string (current production data shape)
519 owner_user_id=handle,
520 )
521 db.add(repo)
522
523 await db.commit()
524 await db.refresh(profile)
525 return profile
526
527
528 # ---------------------------------------------------------------------------
529 # Phase 1 — repos pipeline: repos appear in HTML when owner==handle
530 # ---------------------------------------------------------------------------
531
532
533 async def test_profile_header_repos_appear_when_owner_matches_handle(
534 client: AsyncClient,
535 db_session: AsyncSession,
536 ) -> None:
537 """Repo chips render in header when repos.owner == identity.handle.
538
539 Root cause being fixed: _fetch_repos queried owner_user_id==identity_id
540 (sha256:...) but DB stores owner_user_id==handle string. The fix queries
541 owner==handle so repos always resolve correctly.
542 """
543 await _make_identity_with_repos(
544 db_session,
545 handle="chipuser",
546 repo_slugs=["muse", "stori", "maestro"],
547 )
548 resp = await client.get("/chipuser")
549 assert resp.status_code == 200
550 body = resp.text
551 # All three repo slugs must appear as chip text in the hero
552 assert "MUSE" in body
553 assert "STORI" in body
554 assert "MAESTRO" in body
555
556
557 async def test_profile_header_repo_count_in_json(
558 client: AsyncClient,
559 db_session: AsyncSession,
560 ) -> None:
561 """JSON response repoCount matches seeded repos when owner==handle."""
562 await _make_identity_with_repos(
563 db_session,
564 handle="countuser",
565 repo_slugs=["alpha", "beta", "gamma"],
566 )
567 resp = await client.get("/countuser?format=json")
568 assert resp.status_code == 200
569 data = resp.json()
570 assert data.get("repoCount", 0) == 3
571
572
573 # ---------------------------------------------------------------------------
574 # Phase 2 — bio field: bio renders in header when set
575 # ---------------------------------------------------------------------------
576
577
578 async def test_profile_header_bio_renders_when_set(
579 client: AsyncClient,
580 db_session: AsyncSession,
581 ) -> None:
582 """Bio string appears quoted in the hero body when identity.bio is set."""
583 await _make_identity_with_repos(
584 db_session,
585 handle="biouser",
586 bio="Building the sound of the future",
587 )
588 resp = await client.get("/biouser")
589 assert resp.status_code == 200
590 assert "Building the sound of the future" in resp.text
591
592
593 async def test_profile_header_bio_fallback_when_null(
594 client: AsyncClient,
595 db_session: AsyncSession,
596 ) -> None:
597 """When bio is NULL, the fallback 'member since' line renders instead."""
598 await _make_identity_with_repos(db_session, handle="nobiouser", bio=None)
599 resp = await client.get("/nobiouser")
600 assert resp.status_code == 200
601 assert "member since" in resp.text
602
603
604 # ---------------------------------------------------------------------------
605 # Phase 3 — AVAX address: renders truncated in side card when set
606 # ---------------------------------------------------------------------------
607
608
609 async def test_profile_header_avax_renders_truncated(
610 client: AsyncClient,
611 db_session: AsyncSession,
612 ) -> None:
613 """AVAX address renders as '0x1a2b3c4d5e6f…abc123' (12+ellipsis+6) in side card."""
614 avax = "0x1a2b3c4d5e6f7890abcdef123456"
615 await _make_identity_with_repos(
616 db_session,
617 handle="avaxuser",
618 avax_address=avax,
619 )
620 resp = await client.get("/avaxuser")
621 assert resp.status_code == 200
622 body = resp.text
623 # First 12 chars of address present
624 assert avax[:12] in body
625 # Last 6 chars present
626 assert avax[-6:] in body
627 # "not set" must NOT appear
628 assert "not set" not in body
629
630
631 async def test_profile_header_avax_not_set_shows_placeholder(
632 client: AsyncClient,
633 db_session: AsyncSession,
634 ) -> None:
635 """When avax_address is NULL, side card shows 'not set'."""
636 await _make_identity_with_repos(db_session, handle="noavaxuser", avax_address=None)
637 resp = await client.get("/noavaxuser")
638 assert resp.status_code == 200
639 assert "not set" in resp.text
640
641
642 # ---------------------------------------------------------------------------
643 # Phase 4 — repo chip icons: domain emoji present based on slug
644 # ---------------------------------------------------------------------------
645
646
647 async def test_profile_header_chip_icons_present(
648 client: AsyncClient,
649 db_session: AsyncSession,
650 ) -> None:
651 """Domain emoji icons appear in project chips for known repo slugs.
652
653 muse → 🎵, stori → ⚡, maestro → 🎹
654 """
655 await _make_identity_with_repos(
656 db_session,
657 handle="iconuser",
658 repo_slugs=["muse", "stori", "maestro"],
659 )
660 resp = await client.get("/iconuser")
661 assert resp.status_code == 200
662 body = resp.text
663 assert "🎵" in body # muse
664 assert "⚡" in body # stori
665 assert "🎹" in body # maestro
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago