"""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_identity_models import MusehubIdentity from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubRepo from muse.core.types import long_id, now_utc_iso # --------------------------------------------------------------------------- # 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", now_utc_iso()) 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", branch="main", parent_ids=[], message="initial commit", author="testuser", timestamp=datetime.now(tz=timezone.utc), ) db_session.add(commit) db_session.add(MusehubCommitRef(repo_id=str(repo.repo_id), commit_id="abc123def456abc123def456abc123def456abc1")) 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 # --------------------------------------------------------------------------- # AVAX address visibility # --------------------------------------------------------------------------- async def test_profile_page_html_hides_avax_when_null( client: AsyncClient, db_session: AsyncSession, ) -> None: """Profile HTML does not mention AVAX when avax_address is None.""" await _make_profile(db_session) response = await client.get("/testuser") assert response.status_code == 200 body = response.text assert "AVAX" not in body assert "avax" not in body.lower() # --------------------------------------------------------------------------- # 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 = now_utc_iso() identity_id = long_id(handle.ljust(64, "0")[:64]) 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 async def test_profile_header_repo_count_excludes_private( client: AsyncClient, db_session: AsyncSession, ) -> None: """repoCount on the profile page must only count public repos. Regression guard: the query previously lacked a visibility filter, causing private repos to inflate the displayed count. """ from musehub.core.genesis import compute_repo_id now_iso = now_utc_iso() handle = "privacyuser" identity_id = long_id(handle.ljust(64, "0")[:64]) profile = MusehubIdentity( identity_id=identity_id, handle=handle, identity_type="human", ) db_session.add(profile) await db_session.flush() for slug, visibility in [("pub1", "public"), ("pub2", "public"), ("priv1", "private")]: repo = MusehubRepo( repo_id=compute_repo_id(identity_id, slug, "code", now_iso), name=slug, owner=handle, slug=slug, visibility=visibility, owner_user_id=handle, ) db_session.add(repo) await db_session.commit() resp = await client.get(f"/{handle}?format=json") assert resp.status_code == 200 data = resp.json() assert data.get("repoCount") == 2, ( f"Expected 2 (public only), got {data.get('repoCount')} — " "private repos must be excluded from the profile repo count" ) # --------------------------------------------------------------------------- # 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 async def test_profile_header_avax_hidden_when_null( client: AsyncClient, db_session: AsyncSession, ) -> None: """When avax_address is NULL, AVAX row is hidden entirely — no 'not set' placeholder.""" await _make_identity_with_repos(db_session, handle="noavaxuser", avax_address=None) resp = await client.get("/noavaxuser") assert resp.status_code == 200 assert "AVAX" not in resp.text assert "not set" not in resp.text # --------------------------------------------------------------------------- # Auth key display — identity_id / fingerprint / public_key_b64 # --------------------------------------------------------------------------- # These tests lock in the three-field identity model documented in # /muse/identity#key-rotation: # identity_id — immutable, sha256(first_registered_key_bytes) # fingerprint — per-key, sha256(current_key_bytes), changes on rotation # public_key_b64 — raw Ed25519 key, base64url, changes on rotation # --------------------------------------------------------------------------- async def _make_auth_key( db: AsyncSession, *, identity_id: str, fingerprint: str, public_key_b64: str, algorithm: str = "ed25519", label: str = "", created_at_offset_seconds: int = 0, ) -> None: """Insert a MusehubAuthKey row directly — bypasses the challenge-response flow.""" from datetime import datetime, timezone, timedelta from musehub.db.musehub_auth_models import MusehubAuthKey now = datetime.now(timezone.utc) + timedelta(seconds=created_at_offset_seconds) key = MusehubAuthKey( key_id=fingerprint, # key_id == fingerprint for simplicity in tests identity_id=identity_id, public_key_b64=public_key_b64, fingerprint=fingerprint, algorithm=algorithm, label=label, created_at=now, ) db.add(key) await db.flush() async def test_profile_shows_auth_key_when_registered( client: AsyncClient, db_session: AsyncSession, ) -> None: """Profile hero strip shows algorithm, public_key_b64, and fingerprint when a MusehubAuthKey row exists for the identity.""" identity = await _make_identity_with_repos(db_session, handle="keyuser") pubkey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" fp = "sha256:aaaa000000000000000000000000000000000000000000000000000000000001" await _make_auth_key( db_session, identity_id=identity.identity_id, fingerprint=fp, public_key_b64=pubkey, ) await db_session.commit() resp = await client.get("/keyuser") assert resp.status_code == 200 body = resp.text assert "ed25519" in body assert pubkey in body assert fp in body async def test_profile_fallback_to_identity_id_when_no_key( client: AsyncClient, db_session: AsyncSession, ) -> None: """When no MusehubAuthKey row exists, the profile falls back to displaying the identity_id as the fingerprint — clearly a degraded state.""" identity = await _make_identity_with_repos(db_session, handle="nokeyuser") resp = await client.get("/nokeyuser") assert resp.status_code == 200 body = resp.text # Falls back to identity.user_id (== identity_id) assert identity.identity_id in body # No pubkey row shown — ed25519 label should not appear in strip context # (it may appear elsewhere in the page for other reasons, so we check # that the strip row with the pubkey value is absent) assert "strip-val--mono" in body # strip is rendered # The fallback shows identity_id, not a separate pubkey line assert f'ed25519' not in body async def test_profile_shows_most_recent_key_after_rotation( client: AsyncClient, db_session: AsyncSession, ) -> None: """After key rotation, the profile shows the newest key, not the original. Both keys share the same identity_id — this is the rotation invariant. The original key is still valid but the profile surfaces the current one. """ identity = await _make_identity_with_repos(db_session, handle="rotateduser") old_pubkey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" old_fp = "sha256:aaaa000000000000000000000000000000000000000000000000000000000001" new_pubkey = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" new_fp = "sha256:bbbb000000000000000000000000000000000000000000000000000000000002" # Old key registered first await _make_auth_key( db_session, identity_id=identity.identity_id, fingerprint=old_fp, public_key_b64=old_pubkey, label="original", created_at_offset_seconds=0, ) # New key registered 60s later — simulates muse auth rotate await _make_auth_key( db_session, identity_id=identity.identity_id, fingerprint=new_fp, public_key_b64=new_pubkey, label="rotated", created_at_offset_seconds=60, ) await db_session.commit() resp = await client.get("/rotateduser") assert resp.status_code == 200 body = resp.text # New key displayed assert new_pubkey in body assert new_fp in body # Old key NOT displayed — it's registered but not the current one assert old_pubkey not in body assert old_fp not in body async def test_identity_id_unchanged_across_rotation( client: AsyncClient, db_session: AsyncSession, ) -> None: """The identity_id anchor never changes across key rotations. Two keys exist with different fingerprints but the same identity_id — both rows link back to the single musehub_identities row. """ from musehub.db.musehub_auth_models import MusehubAuthKey from sqlalchemy import select identity = await _make_identity_with_repos(db_session, handle="stableuser") fp1 = "sha256:cccc000000000000000000000000000000000000000000000000000000000001" fp2 = "sha256:dddd000000000000000000000000000000000000000000000000000000000002" await _make_auth_key( db_session, identity_id=identity.identity_id, fingerprint=fp1, public_key_b64="CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", created_at_offset_seconds=0, ) await _make_auth_key( db_session, identity_id=identity.identity_id, fingerprint=fp2, public_key_b64="DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", created_at_offset_seconds=60, ) await db_session.commit() # Both key rows must reference the same identity_id rows = (await db_session.execute( select(MusehubAuthKey) .where(MusehubAuthKey.identity_id == identity.identity_id) .order_by(MusehubAuthKey.created_at) )).scalars().all() assert len(rows) == 2 assert rows[0].identity_id == identity.identity_id assert rows[1].identity_id == identity.identity_id assert rows[0].fingerprint == fp1 assert rows[1].fingerprint == fp2 # identity_id is not a fingerprint of either current key # (it is the fingerprint of the original registration key) assert identity.identity_id not in (fp1, fp2) async def test_profile_auth_key_algorithm_label_present( client: AsyncClient, db_session: AsyncSession, ) -> None: """The algorithm label ('ed25519') renders as a strip-label element, not as raw text mixed into the fingerprint row.""" identity = await _make_identity_with_repos(db_session, handle="algolabeluser") fp = "sha256:eeee000000000000000000000000000000000000000000000000000000000003" await _make_auth_key( db_session, identity_id=identity.identity_id, fingerprint=fp, public_key_b64="EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE", algorithm="ed25519", ) await db_session.commit() resp = await client.get("/algolabeluser") assert resp.status_code == 200 body = resp.text # Algorithm appears as a strip-label, fingerprint on its own row assert 'ed25519' in body assert 'fingerprint' in body # ---------------------------------------------------------------------------