"""TDD spec for Phase 4 — /intel/blast-risk/detail per-symbol page (issue #11). Route: GET /{owner}/{repo_slug}/intel/blast-risk/detail?address= Shows composite score, four sub-score bars with raw input labels, blast_top dependents from musehub_symbol_intel. Unknown or missing address → 200 with empty-state (not 404). Joins blast_risk with symbol_intel for raw values. Pure helpers in ui_intel.py: _risk_color_class(score: float) -> str 0.75+ → "br-score-fill--critical" 0.50+ → "br-score-fill--high" 0.25+ → "br-score-fill--medium" else → "br-score-fill--low" _score_bar_pct(score: float) -> int clamp(round(score * 100), 0, 100) Layers: Route: P4_01 – P4_04 Content: P4_05 – P4_10 Helpers: P4_11 – P4_18 E2E: P4_19 – P4_20 Security: P4_21 – P4_22 """ from __future__ import annotations import secrets import pytest import pytest_asyncio from httpx import AsyncClient from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from muse.core.types import fake_id, long_id from musehub.db.musehub_intel_models import MusehubIntelBlastRisk, MusehubSymbolIntel from musehub.db.musehub_repo_models import MusehubRepo from tests.factories import create_repo def _uid() -> str: return fake_id(secrets.token_hex(16)) _OWNER = "testuser" _SLUG = "brdetailrepo" _REF = long_id("d" * 64) async def _seed_risk( session: AsyncSession, repo_id: str, *, address: str, kind: str = "function", risk: str = "critical", risk_score: int = 90, impact_score: float = 0.8, churn_score: float = 0.7, test_gap_score: float = 1.0, coupling_score: float = 0.6, ) -> None: stmt = ( pg_insert(MusehubIntelBlastRisk) .values( repo_id=repo_id, address=address, kind=kind, risk=risk, risk_score=risk_score, impact_score=impact_score, churn_score=churn_score, test_gap_score=test_gap_score, coupling_score=coupling_score, ref=_REF, ) .on_conflict_do_update( index_elements=["repo_id", "address"], set_={"risk": risk, "risk_score": risk_score}, ) ) await session.execute(stmt) await session.flush() async def _seed_symbol( session: AsyncSession, repo_id: str, *, address: str, blast: int = 20, churn_30d: int = 10, blast_cross: int = 5, blast_top: list[str] | None = None, kind: str = "function", ) -> None: stmt = ( pg_insert(MusehubSymbolIntel) .values( repo_id=repo_id, address=address, symbol_kind=kind, blast=blast, blast_direct=blast, blast_cross=blast_cross, churn=churn_30d, churn_30d=churn_30d, churn_90d=churn_30d, author_count=1, gravity=0.0, weekly=[0] * 12, blast_top=blast_top or [], ) .on_conflict_do_update( index_elements=["repo_id", "address"], set_={"blast": blast, "churn_30d": churn_30d, "blast_top": blast_top or []}, ) ) await session.execute(stmt) await session.flush() # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def detail_repo(db_session: AsyncSession) -> MusehubRepo: return await create_repo(db_session, owner=_OWNER, slug=_SLUG) @pytest_asyncio.fixture async def detail_repo_with_symbol(db_session: AsyncSession, detail_repo: MusehubRepo) -> MusehubRepo: repo_id = detail_repo.repo_id await db_session.commit() await _seed_risk(db_session, repo_id, address="pkg/auth.py::validate_token", risk="critical", risk_score=92, impact_score=0.85, churn_score=0.70, test_gap_score=1.0, coupling_score=0.60) await _seed_symbol(db_session, repo_id, address="pkg/auth.py::validate_token", blast=42, churn_30d=14, blast_cross=6, blast_top=["pkg/api.py::login", "pkg/api.py::logout"]) await db_session.commit() return detail_repo # --------------------------------------------------------------------------- # Layer 1 — Route registration # --------------------------------------------------------------------------- class TestDetailRouteRegistration: def test_P4_01_detail_route_registered(self) -> None: from musehub.api.routes.musehub.ui_intel import router paths = [r.path for r in router.routes] assert any("blast-risk/detail" in p for p in paths) # --------------------------------------------------------------------------- # Layer 2 — HTTP responses # --------------------------------------------------------------------------- class TestDetailHttpResponses: @pytest.mark.asyncio async def test_P4_02_known_address_returns_200( self, client: AsyncClient, detail_repo_with_symbol: MusehubRepo ) -> None: resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail", params={"address": "pkg/auth.py::validate_token"}, ) assert resp.status_code == 200 @pytest.mark.asyncio async def test_P4_03_unknown_address_returns_200_with_empty_state( self, client: AsyncClient, detail_repo: MusehubRepo ) -> None: resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail", params={"address": "no/such.py::ghost_fn"}, ) assert resp.status_code == 200 @pytest.mark.asyncio async def test_P4_04_missing_address_param_returns_200_with_empty_state( self, client: AsyncClient, detail_repo: MusehubRepo ) -> None: resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail") assert resp.status_code == 200 # --------------------------------------------------------------------------- # Layer 3 — Content rendered # --------------------------------------------------------------------------- class TestDetailContent: @pytest.mark.asyncio async def test_P4_05_symbol_address_in_html( self, client: AsyncClient, detail_repo_with_symbol: MusehubRepo ) -> None: resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail", params={"address": "pkg/auth.py::validate_token"}, ) assert "validate_token" in resp.text @pytest.mark.asyncio async def test_P4_06_risk_score_in_html( self, client: AsyncClient, detail_repo_with_symbol: MusehubRepo ) -> None: resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail", params={"address": "pkg/auth.py::validate_token"}, ) assert "92" in resp.text @pytest.mark.asyncio async def test_P4_07_all_four_sub_scores_rendered( self, client: AsyncClient, detail_repo_with_symbol: MusehubRepo ) -> None: resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail", params={"address": "pkg/auth.py::validate_token"}, ) text = resp.text # Each sub-score label must appear assert "impact" in text.lower() assert "churn" in text.lower() assert "test" in text.lower() assert "coupling" in text.lower() @pytest.mark.asyncio async def test_P4_08_risk_tier_badge_rendered( self, client: AsyncClient, detail_repo_with_symbol: MusehubRepo ) -> None: resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail", params={"address": "pkg/auth.py::validate_token"}, ) assert "critical" in resp.text @pytest.mark.asyncio async def test_P4_09_blast_top_dependents_listed( self, client: AsyncClient, detail_repo_with_symbol: MusehubRepo ) -> None: resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail", params={"address": "pkg/auth.py::validate_token"}, ) assert "login" in resp.text assert "logout" in resp.text @pytest.mark.asyncio async def test_P4_10_back_link_to_blast_risk_list( self, client: AsyncClient, detail_repo_with_symbol: MusehubRepo ) -> None: resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail", params={"address": "pkg/auth.py::validate_token"}, ) assert "blast-risk" in resp.text # --------------------------------------------------------------------------- # Layer 4 — Pure helpers (no DB) # --------------------------------------------------------------------------- class TestDetailHelpers: def test_P4_11_risk_color_class_critical(self) -> None: from musehub.api.routes.musehub.ui_intel import _risk_color_class assert _risk_color_class(0.8) == "br-score-fill--critical" def test_P4_12_risk_color_class_high(self) -> None: from musehub.api.routes.musehub.ui_intel import _risk_color_class assert _risk_color_class(0.6) == "br-score-fill--high" def test_P4_13_risk_color_class_medium(self) -> None: from musehub.api.routes.musehub.ui_intel import _risk_color_class assert _risk_color_class(0.3) == "br-score-fill--medium" def test_P4_14_risk_color_class_low(self) -> None: from musehub.api.routes.musehub.ui_intel import _risk_color_class assert _risk_color_class(0.1) == "br-score-fill--low" def test_P4_15_score_bar_pct_zero(self) -> None: from musehub.api.routes.musehub.ui_intel import _score_bar_pct assert _score_bar_pct(0.0) == 0 def test_P4_16_score_bar_pct_one(self) -> None: from musehub.api.routes.musehub.ui_intel import _score_bar_pct assert _score_bar_pct(1.0) == 100 def test_P4_17_score_bar_pct_half(self) -> None: from musehub.api.routes.musehub.ui_intel import _score_bar_pct assert _score_bar_pct(0.5) == 50 def test_P4_18_score_bar_pct_overflow_clamped(self) -> None: from musehub.api.routes.musehub.ui_intel import _score_bar_pct assert _score_bar_pct(1.5) == 100 # --------------------------------------------------------------------------- # Layer 5 — End-to-end # --------------------------------------------------------------------------- class TestDetailE2E: @pytest.mark.asyncio async def test_P4_19_full_seed_to_html_round_trip( self, client: AsyncClient, detail_repo_with_symbol: MusehubRepo ) -> None: resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail", params={"address": "pkg/auth.py::validate_token"}, ) text = resp.text assert "validate_token" in text assert "92" in text assert "login" in text @pytest.mark.asyncio async def test_P4_20_empty_blast_top_renders_without_error( self, client: AsyncClient, db_session: AsyncSession, detail_repo: MusehubRepo ) -> None: repo_id = detail_repo.repo_id await db_session.commit() await _seed_risk(db_session, repo_id, address="pkg/solo.py::solo_fn", risk="low", risk_score=15, impact_score=0.1, churn_score=0.1, test_gap_score=0.5, coupling_score=0.0) await _seed_symbol(db_session, repo_id, address="pkg/solo.py::solo_fn", blast=5, churn_30d=1, blast_top=[]) await db_session.commit() resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail", params={"address": "pkg/solo.py::solo_fn"}, ) assert resp.status_code == 200 assert "solo_fn" in resp.text # --------------------------------------------------------------------------- # Layer 6 — Security # --------------------------------------------------------------------------- class TestDetailSecurity: @pytest.mark.asyncio async def test_P4_21_xss_in_address_param_escaped( self, client: AsyncClient, detail_repo: MusehubRepo ) -> None: xss = "" resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail", params={"address": xss}, ) assert resp.status_code == 200 assert "" not in resp.text @pytest.mark.asyncio async def test_P4_22_path_traversal_in_address_safe( self, client: AsyncClient, detail_repo: MusehubRepo ) -> None: resp = await client.get( f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail", params={"address": "../../etc/passwd"}, ) assert resp.status_code == 200