gabriel / musehub public
test_phase4_blast_risk_detail.py python
374 lines 12.7 KB
Raw
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor ⚠ breaking 20 days ago
1 """TDD spec for Phase 4 — /intel/blast-risk/detail per-symbol page (issue #11).
2
3 Route:
4 GET /{owner}/{repo_slug}/intel/blast-risk/detail?address=<symbol_address>
5
6 Shows composite score, four sub-score bars with raw input labels, blast_top
7 dependents from musehub_symbol_intel. Unknown or missing address → 200 with
8 empty-state (not 404). Joins blast_risk with symbol_intel for raw values.
9
10 Pure helpers in ui_intel.py:
11 _risk_color_class(score: float) -> str
12 0.75+ → "br-score-fill--critical"
13 0.50+ → "br-score-fill--high"
14 0.25+ → "br-score-fill--medium"
15 else → "br-score-fill--low"
16
17 _score_bar_pct(score: float) -> int
18 clamp(round(score * 100), 0, 100)
19
20 Layers:
21 Route: P4_01 – P4_04
22 Content: P4_05 – P4_10
23 Helpers: P4_11 – P4_18
24 E2E: P4_19 – P4_20
25 Security: P4_21 – P4_22
26 """
27 from __future__ import annotations
28
29 import secrets
30
31 import pytest
32 import pytest_asyncio
33 from httpx import AsyncClient
34 from sqlalchemy.dialects.postgresql import insert as pg_insert
35 from sqlalchemy.ext.asyncio import AsyncSession
36
37 from muse.core.types import fake_id, long_id
38 from musehub.db.musehub_intel_models import MusehubIntelBlastRisk, MusehubSymbolIntel
39 from musehub.db.musehub_repo_models import MusehubRepo
40 from tests.factories import create_repo
41
42
43 def _uid() -> str:
44 return fake_id(secrets.token_hex(16))
45
46
47 _OWNER = "testuser"
48 _SLUG = "brdetailrepo"
49 _REF = long_id("d" * 64)
50
51
52 async def _seed_risk(
53 session: AsyncSession,
54 repo_id: str,
55 *,
56 address: str,
57 kind: str = "function",
58 risk: str = "critical",
59 risk_score: int = 90,
60 impact_score: float = 0.8,
61 churn_score: float = 0.7,
62 test_gap_score: float = 1.0,
63 coupling_score: float = 0.6,
64 ) -> None:
65 stmt = (
66 pg_insert(MusehubIntelBlastRisk)
67 .values(
68 repo_id=repo_id,
69 address=address,
70 kind=kind,
71 risk=risk,
72 risk_score=risk_score,
73 impact_score=impact_score,
74 churn_score=churn_score,
75 test_gap_score=test_gap_score,
76 coupling_score=coupling_score,
77 ref=_REF,
78 )
79 .on_conflict_do_update(
80 index_elements=["repo_id", "address"],
81 set_={"risk": risk, "risk_score": risk_score},
82 )
83 )
84 await session.execute(stmt)
85 await session.flush()
86
87
88 async def _seed_symbol(
89 session: AsyncSession,
90 repo_id: str,
91 *,
92 address: str,
93 blast: int = 20,
94 churn_30d: int = 10,
95 blast_cross: int = 5,
96 blast_top: list[str] | None = None,
97 kind: str = "function",
98 ) -> None:
99 stmt = (
100 pg_insert(MusehubSymbolIntel)
101 .values(
102 repo_id=repo_id,
103 address=address,
104 symbol_kind=kind,
105 blast=blast,
106 blast_direct=blast,
107 blast_cross=blast_cross,
108 churn=churn_30d,
109 churn_30d=churn_30d,
110 churn_90d=churn_30d,
111 author_count=1,
112 gravity=0.0,
113 weekly=[0] * 12,
114 blast_top=blast_top or [],
115 )
116 .on_conflict_do_update(
117 index_elements=["repo_id", "address"],
118 set_={"blast": blast, "churn_30d": churn_30d, "blast_top": blast_top or []},
119 )
120 )
121 await session.execute(stmt)
122 await session.flush()
123
124
125 # ---------------------------------------------------------------------------
126 # Fixtures
127 # ---------------------------------------------------------------------------
128
129 @pytest_asyncio.fixture
130 async def detail_repo(db_session: AsyncSession) -> MusehubRepo:
131 return await create_repo(db_session, owner=_OWNER, slug=_SLUG)
132
133
134 @pytest_asyncio.fixture
135 async def detail_repo_with_symbol(db_session: AsyncSession, detail_repo: MusehubRepo) -> MusehubRepo:
136 repo_id = detail_repo.repo_id
137 await db_session.commit()
138 await _seed_risk(db_session, repo_id, address="pkg/auth.py::validate_token",
139 risk="critical", risk_score=92,
140 impact_score=0.85, churn_score=0.70,
141 test_gap_score=1.0, coupling_score=0.60)
142 await _seed_symbol(db_session, repo_id, address="pkg/auth.py::validate_token",
143 blast=42, churn_30d=14, blast_cross=6,
144 blast_top=["pkg/api.py::login", "pkg/api.py::logout"])
145 await db_session.commit()
146 return detail_repo
147
148
149 # ---------------------------------------------------------------------------
150 # Layer 1 — Route registration
151 # ---------------------------------------------------------------------------
152
153 class TestDetailRouteRegistration:
154
155 def test_P4_01_detail_route_registered(self) -> None:
156 from musehub.api.routes.musehub.ui_intel import router
157 paths = [r.path for r in router.routes]
158 assert any("blast-risk/detail" in p for p in paths)
159
160
161 # ---------------------------------------------------------------------------
162 # Layer 2 — HTTP responses
163 # ---------------------------------------------------------------------------
164
165 class TestDetailHttpResponses:
166
167 @pytest.mark.asyncio
168 async def test_P4_02_known_address_returns_200(
169 self, client: AsyncClient, detail_repo_with_symbol: MusehubRepo
170 ) -> None:
171 resp = await client.get(
172 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
173 params={"address": "pkg/auth.py::validate_token"},
174 )
175 assert resp.status_code == 200
176
177 @pytest.mark.asyncio
178 async def test_P4_03_unknown_address_returns_200_with_empty_state(
179 self, client: AsyncClient, detail_repo: MusehubRepo
180 ) -> None:
181 resp = await client.get(
182 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
183 params={"address": "no/such.py::ghost_fn"},
184 )
185 assert resp.status_code == 200
186
187 @pytest.mark.asyncio
188 async def test_P4_04_missing_address_param_returns_200_with_empty_state(
189 self, client: AsyncClient, detail_repo: MusehubRepo
190 ) -> None:
191 resp = await client.get(f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail")
192 assert resp.status_code == 200
193
194
195 # ---------------------------------------------------------------------------
196 # Layer 3 — Content rendered
197 # ---------------------------------------------------------------------------
198
199 class TestDetailContent:
200
201 @pytest.mark.asyncio
202 async def test_P4_05_symbol_address_in_html(
203 self, client: AsyncClient, detail_repo_with_symbol: MusehubRepo
204 ) -> None:
205 resp = await client.get(
206 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
207 params={"address": "pkg/auth.py::validate_token"},
208 )
209 assert "validate_token" in resp.text
210
211 @pytest.mark.asyncio
212 async def test_P4_06_risk_score_in_html(
213 self, client: AsyncClient, detail_repo_with_symbol: MusehubRepo
214 ) -> None:
215 resp = await client.get(
216 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
217 params={"address": "pkg/auth.py::validate_token"},
218 )
219 assert "92" in resp.text
220
221 @pytest.mark.asyncio
222 async def test_P4_07_all_four_sub_scores_rendered(
223 self, client: AsyncClient, detail_repo_with_symbol: MusehubRepo
224 ) -> None:
225 resp = await client.get(
226 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
227 params={"address": "pkg/auth.py::validate_token"},
228 )
229 text = resp.text
230 # Each sub-score label must appear
231 assert "impact" in text.lower()
232 assert "churn" in text.lower()
233 assert "test" in text.lower()
234 assert "coupling" in text.lower()
235
236 @pytest.mark.asyncio
237 async def test_P4_08_risk_tier_badge_rendered(
238 self, client: AsyncClient, detail_repo_with_symbol: MusehubRepo
239 ) -> None:
240 resp = await client.get(
241 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
242 params={"address": "pkg/auth.py::validate_token"},
243 )
244 assert "critical" in resp.text
245
246 @pytest.mark.asyncio
247 async def test_P4_09_blast_top_dependents_listed(
248 self, client: AsyncClient, detail_repo_with_symbol: MusehubRepo
249 ) -> None:
250 resp = await client.get(
251 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
252 params={"address": "pkg/auth.py::validate_token"},
253 )
254 assert "login" in resp.text
255 assert "logout" in resp.text
256
257 @pytest.mark.asyncio
258 async def test_P4_10_back_link_to_blast_risk_list(
259 self, client: AsyncClient, detail_repo_with_symbol: MusehubRepo
260 ) -> None:
261 resp = await client.get(
262 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
263 params={"address": "pkg/auth.py::validate_token"},
264 )
265 assert "blast-risk" in resp.text
266
267
268 # ---------------------------------------------------------------------------
269 # Layer 4 — Pure helpers (no DB)
270 # ---------------------------------------------------------------------------
271
272 class TestDetailHelpers:
273
274 def test_P4_11_risk_color_class_critical(self) -> None:
275 from musehub.api.routes.musehub.ui_intel import _risk_color_class
276 assert _risk_color_class(0.8) == "br-score-fill--critical"
277
278 def test_P4_12_risk_color_class_high(self) -> None:
279 from musehub.api.routes.musehub.ui_intel import _risk_color_class
280 assert _risk_color_class(0.6) == "br-score-fill--high"
281
282 def test_P4_13_risk_color_class_medium(self) -> None:
283 from musehub.api.routes.musehub.ui_intel import _risk_color_class
284 assert _risk_color_class(0.3) == "br-score-fill--medium"
285
286 def test_P4_14_risk_color_class_low(self) -> None:
287 from musehub.api.routes.musehub.ui_intel import _risk_color_class
288 assert _risk_color_class(0.1) == "br-score-fill--low"
289
290 def test_P4_15_score_bar_pct_zero(self) -> None:
291 from musehub.api.routes.musehub.ui_intel import _score_bar_pct
292 assert _score_bar_pct(0.0) == 0
293
294 def test_P4_16_score_bar_pct_one(self) -> None:
295 from musehub.api.routes.musehub.ui_intel import _score_bar_pct
296 assert _score_bar_pct(1.0) == 100
297
298 def test_P4_17_score_bar_pct_half(self) -> None:
299 from musehub.api.routes.musehub.ui_intel import _score_bar_pct
300 assert _score_bar_pct(0.5) == 50
301
302 def test_P4_18_score_bar_pct_overflow_clamped(self) -> None:
303 from musehub.api.routes.musehub.ui_intel import _score_bar_pct
304 assert _score_bar_pct(1.5) == 100
305
306
307 # ---------------------------------------------------------------------------
308 # Layer 5 — End-to-end
309 # ---------------------------------------------------------------------------
310
311 class TestDetailE2E:
312
313 @pytest.mark.asyncio
314 async def test_P4_19_full_seed_to_html_round_trip(
315 self, client: AsyncClient, detail_repo_with_symbol: MusehubRepo
316 ) -> None:
317 resp = await client.get(
318 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
319 params={"address": "pkg/auth.py::validate_token"},
320 )
321 text = resp.text
322 assert "validate_token" in text
323 assert "92" in text
324 assert "login" in text
325
326 @pytest.mark.asyncio
327 async def test_P4_20_empty_blast_top_renders_without_error(
328 self, client: AsyncClient, db_session: AsyncSession, detail_repo: MusehubRepo
329 ) -> None:
330 repo_id = detail_repo.repo_id
331 await db_session.commit()
332 await _seed_risk(db_session, repo_id, address="pkg/solo.py::solo_fn",
333 risk="low", risk_score=15,
334 impact_score=0.1, churn_score=0.1,
335 test_gap_score=0.5, coupling_score=0.0)
336 await _seed_symbol(db_session, repo_id, address="pkg/solo.py::solo_fn",
337 blast=5, churn_30d=1, blast_top=[])
338 await db_session.commit()
339
340 resp = await client.get(
341 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
342 params={"address": "pkg/solo.py::solo_fn"},
343 )
344 assert resp.status_code == 200
345 assert "solo_fn" in resp.text
346
347
348 # ---------------------------------------------------------------------------
349 # Layer 6 — Security
350 # ---------------------------------------------------------------------------
351
352 class TestDetailSecurity:
353
354 @pytest.mark.asyncio
355 async def test_P4_21_xss_in_address_param_escaped(
356 self, client: AsyncClient, detail_repo: MusehubRepo
357 ) -> None:
358 xss = "<script>alert(1)</script>"
359 resp = await client.get(
360 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
361 params={"address": xss},
362 )
363 assert resp.status_code == 200
364 assert "<script>alert(1)</script>" not in resp.text
365
366 @pytest.mark.asyncio
367 async def test_P4_22_path_traversal_in_address_safe(
368 self, client: AsyncClient, detail_repo: MusehubRepo
369 ) -> None:
370 resp = await client.get(
371 f"/{_OWNER}/{_SLUG}/intel/blast-risk/detail",
372 params={"address": "../../etc/passwd"},
373 )
374 assert resp.status_code == 200
File History 1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 20 days ago