"""Tests for the enhanced MuseHub user profile page. Covers: - test_profile_page_html_returns_200 — GET /users/{username} returns 200 HTML - test_profile_page_no_auth_required — accessible without authentication - test_profile_page_unknown_user_still_renders — unknown username still returns 200 HTML shell - test_profile_page_html_contains_heatmap_js — page includes heatmap rendering JavaScript - test_profile_page_html_contains_badge_js — page includes badge rendering JavaScript - test_profile_page_html_contains_pinned_js — page includes pinned repos JavaScript - test_profile_page_html_contains_activity_tab — page includes Activity tab - test_profile_page_json_returns_200 — ?format=json returns 200 JSON - test_profile_page_json_unknown_user_404 — ?format=json returns 404 for unknown user - test_profile_page_json_heatmap_structure — JSON response has heatmap with days/stats - test_profile_page_json_badges_structure — JSON response has 8 badges with expected fields - test_profile_page_json_pinned_repos — JSON response includes pinned repo cards - test_profile_page_json_activity_empty — JSON response returns empty activity for new user - test_profile_page_json_activity_filter — ?tab=commits filters activity to commits only - test_profile_page_json_badge_first_commit_earned — first_commit badge earned after seeding a commit - test_profile_page_json_camel_case_keys — JSON keys are camelCase """ from __future__ import annotations import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from musehub.db.musehub_models import MusehubCommit, MusehubIdentity, MusehubRepo # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- async def _make_profile( db: AsyncSession, *, username: str = "testuser", user_id: str = "user-profile-test-001", bio: str | None = "Test bio", ) -> MusehubIdentity: """Seed a minimal MusehubIdentity.""" profile = MusehubIdentity( identity_id=user_id, handle=username, identity_type="human", bio=bio, avatar_url=None, ) db.add(profile) await db.commit() await db.refresh(profile) return profile async def _make_repo( db: AsyncSession, *, owner_user_id: str = "user-profile-test-001", owner: str = "testuser", name: str = "test-beats", slug: str = "test-beats", visibility: str = "public", ) -> MusehubRepo: """Seed a minimal MusehubRepo.""" from datetime import datetime, timezone from musehub.core.genesis import compute_repo_id repo_id = compute_repo_id(owner_user_id, slug, "code", datetime.now(timezone.utc).isoformat()) repo = MusehubRepo( repo_id=repo_id, name=name, owner=owner, slug=slug, visibility=visibility, owner_user_id=owner_user_id, ) db.add(repo) await db.commit() await db.refresh(repo) return repo # --------------------------------------------------------------------------- # HTML path tests # --------------------------------------------------------------------------- async def test_profile_page_html_returns_200( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET /users/{username} returns 200 HTML for any username.""" await _make_profile(db_session) response = await client.get("/testuser") assert response.status_code == 200 assert "text/html" in response.headers["content-type"] async def test_profile_page_no_auth_required( client: AsyncClient, db_session: AsyncSession, ) -> None: """Profile page is publicly accessible without authentication.""" await _make_profile(db_session) response = await client.get("/testuser") assert response.status_code == 200 async def test_profile_page_unknown_user_still_renders( client: AsyncClient, ) -> None: """HTML shell renders even for unknown users — data fetched client-side.""" response = await client.get("/nobody-exists-xyzzy") assert response.status_code == 200 assert "text/html" in response.headers["content-type"] async def test_profile_page_html_contains_heatmap_js( client: AsyncClient, db_session: AsyncSession, ) -> None: """HTML dispatches the user-profile TypeScript module (heatmap rendered client-side).""" await _make_profile(db_session) response = await client.get("/testuser") assert response.status_code == 200 body = response.text # renderHeatmap moved to app.js; page dispatch JSON confirms module will run assert '"page": "user-profile"' in body assert '"username": "testuser"' in body async def test_profile_page_html_contains_badge_js( client: AsyncClient, db_session: AsyncSession, ) -> None: """HTML dispatches user-profile module which renders badges client-side.""" await _make_profile(db_session) response = await client.get("/testuser") assert response.status_code == 200 body = response.text # renderBadges moved to app.js; verify page dispatch and profile container assert '"page": "user-profile"' in body assert "profile-container" in body or "content" in body async def test_profile_page_html_contains_pinned_js( client: AsyncClient, db_session: AsyncSession, ) -> None: """HTML dispatches user-profile module which renders pinned repos client-side.""" await _make_profile(db_session) response = await client.get("/testuser") assert response.status_code == 200 body = response.text # renderPinned moved to app.js; verify page dispatch JSON assert '"page": "user-profile"' in body assert "testuser" in body async def test_profile_page_html_contains_activity_tab( client: AsyncClient, db_session: AsyncSession, ) -> None: """HTML renders the profile page with user-profile page dispatch (activity driven by JS).""" await _make_profile(db_session) response = await client.get("/testuser") assert response.status_code == 200 body = response.text # Reimagined template: activity sections are data-driven; module dispatch always present assert '"page": "user-profile"' in body assert "testuser" in body # --------------------------------------------------------------------------- # JSON path tests # --------------------------------------------------------------------------- async def test_profile_page_json_returns_200( client: AsyncClient, db_session: AsyncSession, ) -> None: """GET /users/{username}?format=json returns 200 JSON.""" await _make_profile(db_session) response = await client.get("/testuser?format=json") assert response.status_code == 200 assert "application/json" in response.headers["content-type"] async def test_profile_page_json_unknown_user_404( client: AsyncClient, ) -> None: """?format=json returns 404 for an unknown username.""" response = await client.get("/nobody-exists-xyzzy?format=json") assert response.status_code == 404 async def test_profile_page_json_heatmap_structure( client: AsyncClient, db_session: AsyncSession, ) -> None: """JSON response contains heatmap with days list and aggregate stats.""" await _make_profile(db_session) response = await client.get("/testuser?format=json") assert response.status_code == 200 body = response.json() assert "heatmap" in body heatmap = body["heatmap"] assert "days" in heatmap assert "totalContributions" in heatmap assert "longestStreak" in heatmap assert "currentStreak" in heatmap # Should have ~364 days (52 weeks × 7 days) assert len(heatmap["days"]) >= 360 # Each day has date, count, intensity first_day = heatmap["days"][0] assert "date" in first_day assert "count" in first_day assert "intensity" in first_day assert first_day["intensity"] in (0, 1, 2, 3) async def test_profile_page_json_badges_structure( client: AsyncClient, db_session: AsyncSession, ) -> None: """JSON response contains exactly 8 badges with required fields.""" await _make_profile(db_session) response = await client.get("/testuser?format=json") assert response.status_code == 200 body = response.json() assert "badges" in body badges = body["badges"] assert len(badges) == 8 for badge in badges: assert "id" in badge assert "name" in badge assert "description" in badge assert "icon" in badge assert "earned" in badge assert isinstance(badge["earned"], bool) async def test_profile_page_json_pinned_repos( client: AsyncClient, db_session: AsyncSession, ) -> None: """JSON response includes pinned repo cards when pinned_repo_ids are set.""" profile = await _make_profile(db_session) repo = await _make_repo(db_session) # Pin the repo profile.pinned_repo_ids = [repo.repo_id] db_session.add(profile) await db_session.commit() response = await client.get("/testuser?format=json") assert response.status_code == 200 body = response.json() assert "pinnedRepos" in body pinned = body["pinnedRepos"] assert len(pinned) == 1 card = pinned[0] assert card["name"] == "test-beats" assert card["slug"] == "test-beats" assert "forkCount" in card async def test_profile_page_json_activity_empty( client: AsyncClient, db_session: AsyncSession, ) -> None: """JSON response returns empty activity list for a new user with no events.""" await _make_profile(db_session) response = await client.get("/testuser?format=json") assert response.status_code == 200 body = response.json() assert "activity" in body assert isinstance(body["activity"], list) assert body["totalEvents"] == 0 assert body["page"] == 1 assert body["perPage"] == 20 async def test_profile_page_json_activity_filter( client: AsyncClient, db_session: AsyncSession, ) -> None: """?tab=commits filters activity response to commits-only event types.""" await _make_profile(db_session) response = await client.get("/testuser?format=json&tab=commits") assert response.status_code == 200 body = response.json() assert body["activityFilter"] == "commits" async def test_profile_page_json_badge_first_commit_earned( client: AsyncClient, db_session: AsyncSession, ) -> None: """first_commit badge is earned after the user has at least one commit.""" from datetime import datetime, timezone profile = await _make_profile(db_session) repo = await _make_repo(db_session) # Seed one commit owned by this user's repo commit = MusehubCommit( commit_id="abc123def456abc123def456abc123def456abc1", repo_id=repo.repo_id, branch="main", parent_ids=[], message="initial commit", author="testuser", timestamp=datetime.now(tz=timezone.utc), ) db_session.add(commit) await db_session.commit() response = await client.get("/testuser?format=json") assert response.status_code == 200 body = response.json() badges = {b["id"]: b for b in body["badges"]} assert "first_commit" in badges assert badges["first_commit"]["earned"] is True async def test_profile_page_json_camel_case_keys( client: AsyncClient, db_session: AsyncSession, ) -> None: """JSON response uses camelCase keys throughout (no snake_case at top level).""" await _make_profile(db_session) response = await client.get("/testuser?format=json") assert response.status_code == 200 body = response.json() # Top-level camelCase keys assert "avatarUrl" in body assert "totalEvents" in body assert "activityFilter" in body assert "pinnedRepos" in body # No snake_case variants assert "avatar_url" not in body assert "total_events" not in body assert "pinned_repos" not in body # --------------------------------------------------------------------------- # Issue #448 — rich artist profiles with CC attribution fields # --------------------------------------------------------------------------- async def test_profile_model_rich_fields_stored_and_retrieved( db_session: AsyncSession, ) -> None: """MusehubIdentity stores and retrieves all CC-attribution fields added. Regression: before this fix, display_name / location / website_url / social_url / is_verified / cc_license did not exist on the model or schema; saving them would silently discard the data. """ profile = MusehubIdentity( identity_id="user-test-cc-001", handle="kevin_macleod_test", display_name="Kevin MacLeod", bio="Prolific composer. Every genre. Royalty-free forever.", location="Sandpoint, Idaho", website_url="https://incompetech.com", social_url="kmacleod", is_verified=True, cc_license="CC BY 4.0", ) db_session.add(profile) await db_session.commit() await db_session.refresh(profile) assert profile.display_name == "Kevin MacLeod" assert profile.location == "Sandpoint, Idaho" assert profile.website_url == "https://incompetech.com" assert profile.social_url == "kmacleod" assert profile.is_verified is True assert profile.cc_license == "CC BY 4.0" async def test_profile_model_verified_defaults_false( db_session: AsyncSession, ) -> None: """is_verified defaults to False for community users — no accidental verification.""" profile = MusehubIdentity( identity_id="user-test-community-002", handle="community_user_test", bio="Just a regular community user.", ) db_session.add(profile) await db_session.commit() await db_session.refresh(profile) assert profile.is_verified is False assert profile.cc_license is None assert profile.display_name is None assert profile.location is None assert profile.social_url is None async def test_profile_model_public_domain_artist( db_session: AsyncSession, ) -> None: """Public Domain composers get is_verified=True and cc_license='Public Domain'.""" profile = MusehubIdentity( identity_id="user-test-bach-003", handle="bach_test", display_name="Johann Sebastian Bach", bio="Baroque composer. 48 preludes, 48 fugues.", location="Leipzig, Saxony (1723-1750)", website_url="https://www.bach-digital.de", social_url=None, is_verified=True, cc_license="Public Domain", ) db_session.add(profile) await db_session.commit() await db_session.refresh(profile) assert profile.is_verified is True assert profile.cc_license == "Public Domain" assert profile.social_url is None async def test_profile_page_json_includes_verified_and_license( client: AsyncClient, db_session: AsyncSession, ) -> None: """Profile JSON endpoint exposes isVerified and ccLicense fields for CC artists.""" profile = MusehubIdentity( identity_id="user-test-cc-api-004", handle="kai_engel_test", display_name="Kai Engel", bio="Ambient architect. Long-form textures.", location="Germany", website_url="https://freemusicarchive.org/music/Kai_Engel", social_url=None, is_verified=True, cc_license="CC BY 4.0", ) db_session.add(profile) await db_session.commit() response = await client.get("/kai_engel_test?format=json") assert response.status_code == 200 body = response.json() # The profile card must surface verification status and license so the # frontend can render the CC badge without a secondary API call. assert body.get("isVerified") is True assert body.get("ccLicense") == "CC BY 4.0" # =========================================================================== # Profile Header Reimagination — TDD tests (Issue #1) # Phase 1: repos pipeline (owner query) # Phase 2: bio field # Phase 3: AVAX address # Phase 4: repo chip domain icons # =========================================================================== # --------------------------------------------------------------------------- # Helpers shared by header tests # --------------------------------------------------------------------------- async def _make_identity_with_repos( db: AsyncSession, *, handle: str = "herouser", bio: str | None = None, avax_address: str | None = None, repo_slugs: list[str] | None = None, ) -> MusehubIdentity: """Seed a MusehubIdentity + repos where owner==handle (realistic data shape). owner_user_id is set to the handle string — matching production data where repos were created before the identity_id was stable. The repo pipeline fix must resolve repos via owner==handle, not owner_user_id==identity_id. """ from datetime import datetime, timezone from musehub.core.genesis import compute_repo_id now_iso = datetime.now(timezone.utc).isoformat() identity_id = f"sha256:{handle.ljust(64, '0')}"[:71] profile = MusehubIdentity( identity_id=identity_id, handle=handle, identity_type="human", bio=bio, avax_address=avax_address, avatar_url=None, ) db.add(profile) await db.flush() for slug in (repo_slugs or []): repo_id = compute_repo_id(identity_id, slug, "code", now_iso) repo = MusehubRepo( repo_id=repo_id, name=slug, owner=handle, slug=slug, visibility="public", # owner_user_id stores the handle string (current production data shape) owner_user_id=handle, ) db.add(repo) await db.commit() await db.refresh(profile) return profile # --------------------------------------------------------------------------- # Phase 1 — repos pipeline: repos appear in HTML when owner==handle # --------------------------------------------------------------------------- async def test_profile_header_repos_appear_when_owner_matches_handle( client: AsyncClient, db_session: AsyncSession, ) -> None: """Repo chips render in header when repos.owner == identity.handle. Root cause being fixed: _fetch_repos queried owner_user_id==identity_id (sha256:...) but DB stores owner_user_id==handle string. The fix queries owner==handle so repos always resolve correctly. """ await _make_identity_with_repos( db_session, handle="chipuser", repo_slugs=["muse", "stori", "maestro"], ) resp = await client.get("/chipuser") assert resp.status_code == 200 body = resp.text # All three repo slugs must appear as chip text in the hero assert "MUSE" in body assert "STORI" in body assert "MAESTRO" in body async def test_profile_header_repo_count_in_json( client: AsyncClient, db_session: AsyncSession, ) -> None: """JSON response repoCount matches seeded repos when owner==handle.""" await _make_identity_with_repos( db_session, handle="countuser", repo_slugs=["alpha", "beta", "gamma"], ) resp = await client.get("/countuser?format=json") assert resp.status_code == 200 data = resp.json() assert data.get("repoCount", 0) == 3 # --------------------------------------------------------------------------- # Phase 2 — bio field: bio renders in header when set # --------------------------------------------------------------------------- async def test_profile_header_bio_renders_when_set( client: AsyncClient, db_session: AsyncSession, ) -> None: """Bio string appears quoted in the hero body when identity.bio is set.""" await _make_identity_with_repos( db_session, handle="biouser", bio="Building the sound of the future", ) resp = await client.get("/biouser") assert resp.status_code == 200 assert "Building the sound of the future" in resp.text async def test_profile_header_bio_fallback_when_null( client: AsyncClient, db_session: AsyncSession, ) -> None: """When bio is NULL, the fallback 'member since' line renders instead.""" await _make_identity_with_repos(db_session, handle="nobiouser", bio=None) resp = await client.get("/nobiouser") assert resp.status_code == 200 assert "member since" in resp.text # --------------------------------------------------------------------------- # Phase 3 — AVAX address: renders truncated in side card when set # --------------------------------------------------------------------------- async def test_profile_header_avax_renders_truncated( client: AsyncClient, db_session: AsyncSession, ) -> None: """AVAX address renders as '0x1a2b3c4d5e6f…abc123' (12+ellipsis+6) in side card.""" avax = "0x1a2b3c4d5e6f7890abcdef123456" await _make_identity_with_repos( db_session, handle="avaxuser", avax_address=avax, ) resp = await client.get("/avaxuser") assert resp.status_code == 200 body = resp.text # First 12 chars of address present assert avax[:12] in body # Last 6 chars present assert avax[-6:] in body # "not set" must NOT appear assert "not set" not in body async def test_profile_header_avax_not_set_shows_placeholder( client: AsyncClient, db_session: AsyncSession, ) -> None: """When avax_address is NULL, side card shows 'not set'.""" await _make_identity_with_repos(db_session, handle="noavaxuser", avax_address=None) resp = await client.get("/noavaxuser") assert resp.status_code == 200 assert "not set" in resp.text # --------------------------------------------------------------------------- # Phase 4 — repo chip icons: domain emoji present based on slug # --------------------------------------------------------------------------- async def test_profile_header_chip_icons_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """Domain emoji icons appear in project chips for known repo slugs. muse → 🎵, stori → ⚡, maestro → 🎹 """ await _make_identity_with_repos( db_session, handle="iconuser", repo_slugs=["muse", "stori", "maestro"], ) resp = await client.get("/iconuser") assert resp.status_code == 200 body = resp.text assert "🎵" in body # muse assert "⚡" in body # stori assert "🎹" in body # maestro