gabriel / musehub public
test_musehub_ui_user_profile.py python
661 lines 22.0 KB
Raw
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ breaking 145 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 # AVAX address visibility
360 # ---------------------------------------------------------------------------
361
362
363 async def test_profile_page_html_hides_avax_when_null(
364 client: AsyncClient,
365 db_session: AsyncSession,
366 ) -> None:
367 """Profile HTML does not mention AVAX when avax_address is None."""
368 await _make_profile(db_session)
369 response = await client.get("/testuser")
370 assert response.status_code == 200
371 body = response.text
372 assert "AVAX" not in body
373 assert "avax" not in body.lower()
374
375
376 # ---------------------------------------------------------------------------
377 # Issue #448 — rich artist profiles with CC attribution fields
378 # ---------------------------------------------------------------------------
379
380
381 async def test_profile_model_rich_fields_stored_and_retrieved(
382 db_session: AsyncSession,
383 ) -> None:
384 """MusehubIdentity stores and retrieves all CC-attribution fields added.
385
386 Regression: before this fix, display_name / location / website_url /
387 social_url / is_verified / cc_license did not exist on the model or
388 schema; saving them would silently discard the data.
389 """
390 profile = MusehubIdentity(
391 identity_id="user-test-cc-001",
392 handle="kevin_macleod_test",
393 display_name="Kevin MacLeod",
394 bio="Prolific composer. Every genre. Royalty-free forever.",
395 location="Sandpoint, Idaho",
396 website_url="https://incompetech.com",
397 social_url="kmacleod",
398 is_verified=True,
399 cc_license="CC BY 4.0",
400 )
401 db_session.add(profile)
402 await db_session.commit()
403 await db_session.refresh(profile)
404
405 assert profile.display_name == "Kevin MacLeod"
406 assert profile.location == "Sandpoint, Idaho"
407 assert profile.website_url == "https://incompetech.com"
408 assert profile.social_url == "kmacleod"
409 assert profile.is_verified is True
410 assert profile.cc_license == "CC BY 4.0"
411
412
413 async def test_profile_model_verified_defaults_false(
414 db_session: AsyncSession,
415 ) -> None:
416 """is_verified defaults to False for community users — no accidental verification."""
417 profile = MusehubIdentity(
418 identity_id="user-test-community-002",
419 handle="community_user_test",
420 bio="Just a regular community user.",
421 )
422 db_session.add(profile)
423 await db_session.commit()
424 await db_session.refresh(profile)
425
426 assert profile.is_verified is False
427 assert profile.cc_license is None
428 assert profile.display_name is None
429 assert profile.location is None
430 assert profile.social_url is None
431
432
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 = MusehubIdentity(
438 identity_id="user-test-bach-003",
439 handle="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 social_url=None,
445 is_verified=True,
446 cc_license="Public Domain",
447 )
448 db_session.add(profile)
449 await db_session.commit()
450 await db_session.refresh(profile)
451
452 assert profile.is_verified is True
453 assert profile.cc_license == "Public Domain"
454 assert profile.social_url is None
455
456
457 async def test_profile_page_json_includes_verified_and_license(
458 client: AsyncClient,
459 db_session: AsyncSession,
460 ) -> None:
461 """Profile JSON endpoint exposes isVerified and ccLicense fields for CC artists."""
462 profile = MusehubIdentity(
463 identity_id="user-test-cc-api-004",
464 handle="kai_engel_test",
465 display_name="Kai Engel",
466 bio="Ambient architect. Long-form textures.",
467 location="Germany",
468 website_url="https://freemusicarchive.org/music/Kai_Engel",
469 social_url=None,
470 is_verified=True,
471 cc_license="CC BY 4.0",
472 )
473 db_session.add(profile)
474 await db_session.commit()
475
476 response = await client.get("/kai_engel_test?format=json")
477 assert response.status_code == 200
478 body = response.json()
479
480 # The profile card must surface verification status and license so the
481 # frontend can render the CC badge without a secondary API call.
482 assert body.get("isVerified") is True
483 assert body.get("ccLicense") == "CC BY 4.0"
484
485
486 # ===========================================================================
487 # Profile Header Reimagination — TDD tests (Issue #1)
488 # Phase 1: repos pipeline (owner query)
489 # Phase 2: bio field
490 # Phase 3: AVAX address
491 # Phase 4: repo chip domain icons
492 # ===========================================================================
493
494 # ---------------------------------------------------------------------------
495 # Helpers shared by header tests
496 # ---------------------------------------------------------------------------
497
498 async def _make_identity_with_repos(
499 db: AsyncSession,
500 *,
501 handle: str = "herouser",
502 bio: str | None = None,
503 avax_address: str | None = None,
504 repo_slugs: list[str] | None = None,
505 ) -> MusehubIdentity:
506 """Seed a MusehubIdentity + repos where owner==handle (realistic data shape).
507
508 owner_user_id is set to the handle string — matching production data where
509 repos were created before the identity_id was stable. The repo pipeline fix
510 must resolve repos via owner==handle, not owner_user_id==identity_id.
511 """
512 from datetime import datetime, timezone
513 from musehub.core.genesis import compute_repo_id
514
515 now_iso = datetime.now(timezone.utc).isoformat()
516 identity_id = f"sha256:{handle.ljust(64, '0')}"[:71]
517 profile = MusehubIdentity(
518 identity_id=identity_id,
519 handle=handle,
520 identity_type="human",
521 bio=bio,
522 avax_address=avax_address,
523 avatar_url=None,
524 )
525 db.add(profile)
526 await db.flush()
527
528 for slug in (repo_slugs or []):
529 repo_id = compute_repo_id(identity_id, slug, "code", now_iso)
530 repo = MusehubRepo(
531 repo_id=repo_id,
532 name=slug,
533 owner=handle,
534 slug=slug,
535 visibility="public",
536 # owner_user_id stores the handle string (current production data shape)
537 owner_user_id=handle,
538 )
539 db.add(repo)
540
541 await db.commit()
542 await db.refresh(profile)
543 return profile
544
545
546 # ---------------------------------------------------------------------------
547 # Phase 1 — repos pipeline: repos appear in HTML when owner==handle
548 # ---------------------------------------------------------------------------
549
550
551 async def test_profile_header_repos_appear_when_owner_matches_handle(
552 client: AsyncClient,
553 db_session: AsyncSession,
554 ) -> None:
555 """Repo chips render in header when repos.owner == identity.handle.
556
557 Root cause being fixed: _fetch_repos queried owner_user_id==identity_id
558 (sha256:...) but DB stores owner_user_id==handle string. The fix queries
559 owner==handle so repos always resolve correctly.
560 """
561 await _make_identity_with_repos(
562 db_session,
563 handle="chipuser",
564 repo_slugs=["muse", "stori", "maestro"],
565 )
566 resp = await client.get("/chipuser")
567 assert resp.status_code == 200
568 body = resp.text
569 # All three repo slugs must appear as chip text in the hero
570 assert "MUSE" in body
571 assert "STORI" in body
572 assert "MAESTRO" in body
573
574
575 async def test_profile_header_repo_count_in_json(
576 client: AsyncClient,
577 db_session: AsyncSession,
578 ) -> None:
579 """JSON response repoCount matches seeded repos when owner==handle."""
580 await _make_identity_with_repos(
581 db_session,
582 handle="countuser",
583 repo_slugs=["alpha", "beta", "gamma"],
584 )
585 resp = await client.get("/countuser?format=json")
586 assert resp.status_code == 200
587 data = resp.json()
588 assert data.get("repoCount", 0) == 3
589
590
591 # ---------------------------------------------------------------------------
592 # Phase 2 — bio field: bio renders in header when set
593 # ---------------------------------------------------------------------------
594
595
596 async def test_profile_header_bio_renders_when_set(
597 client: AsyncClient,
598 db_session: AsyncSession,
599 ) -> None:
600 """Bio string appears quoted in the hero body when identity.bio is set."""
601 await _make_identity_with_repos(
602 db_session,
603 handle="biouser",
604 bio="Building the sound of the future",
605 )
606 resp = await client.get("/biouser")
607 assert resp.status_code == 200
608 assert "Building the sound of the future" in resp.text
609
610
611 async def test_profile_header_bio_fallback_when_null(
612 client: AsyncClient,
613 db_session: AsyncSession,
614 ) -> None:
615 """When bio is NULL, the fallback 'member since' line renders instead."""
616 await _make_identity_with_repos(db_session, handle="nobiouser", bio=None)
617 resp = await client.get("/nobiouser")
618 assert resp.status_code == 200
619 assert "member since" in resp.text
620
621
622 # ---------------------------------------------------------------------------
623 # Phase 3 — AVAX address: renders truncated in side card when set
624 # ---------------------------------------------------------------------------
625
626
627 async def test_profile_header_avax_renders_truncated(
628 client: AsyncClient,
629 db_session: AsyncSession,
630 ) -> None:
631 """AVAX address renders as '0x1a2b3c4d5e6f…abc123' (12+ellipsis+6) in side card."""
632 avax = "0x1a2b3c4d5e6f7890abcdef123456"
633 await _make_identity_with_repos(
634 db_session,
635 handle="avaxuser",
636 avax_address=avax,
637 )
638 resp = await client.get("/avaxuser")
639 assert resp.status_code == 200
640 body = resp.text
641 # First 12 chars of address present
642 assert avax[:12] in body
643 # Last 6 chars present
644 assert avax[-6:] in body
645 # "not set" must NOT appear
646 assert "not set" not in body
647
648
649 async def test_profile_header_avax_hidden_when_null(
650 client: AsyncClient,
651 db_session: AsyncSession,
652 ) -> None:
653 """When avax_address is NULL, AVAX row is hidden entirely — no 'not set' placeholder."""
654 await _make_identity_with_repos(db_session, handle="noavaxuser", avax_address=None)
655 resp = await client.get("/noavaxuser")
656 assert resp.status_code == 200
657 assert "AVAX" not in resp.text
658 assert "not set" not in resp.text
659
660
661 # ---------------------------------------------------------------------------
File History 1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ 145 days ago