test_phase1_blast_risk_provider.py
python
sha256:0997d6250ae6476362f6fe2025af7789f46d03df3e9f34356d5e8ee79b201923
fix(issues): use issue number as pagination cursor, not cre…
Sonnet 4.6
patch
8 days ago
| 1 | """TDD spec for Phase 1 — SQL-derived BlastRiskProvider (issue #11). |
| 2 | |
| 3 | BlastRiskProvider replaces the muse-CLI subprocess approach with a pure SQL |
| 4 | derivation from `musehub_symbol_intel` blast and churn columns. |
| 5 | |
| 6 | Risk score formula: |
| 7 | impact_score = min(blast / 50.0, 1.0) — normalized blast radius |
| 8 | churn_score = min(churn_30d / 20.0, 1.0) — normalized 30-day churn |
| 9 | test_gap_score = 1.0 — no coverage data → worst case |
| 10 | coupling_score = min(blast_cross / 10.0, 1.0) — normalized cross-domain blast |
| 11 | |
| 12 | risk_score = round( |
| 13 | impact_score * 40 + |
| 14 | churn_score * 25 + |
| 15 | test_gap_score * 20 + |
| 16 | coupling_score * 15 |
| 17 | ) |
| 18 | |
| 19 | Risk tiers: |
| 20 | critical → risk_score >= 75 |
| 21 | high → risk_score >= 50 |
| 22 | medium → risk_score >= 25 |
| 23 | low → risk_score < 25 |
| 24 | |
| 25 | tracked_kinds = {function, async_function, method, async_method, class} |
| 26 | Only symbols with blast > 0 are candidates. |
| 27 | |
| 28 | Layers: |
| 29 | Unit (no DB): |
| 30 | 1. Registry — "intel.code.blast_risk" in _PROVIDER_REGISTRY |
| 31 | 2. Protocol — satisfies IntelProvider |
| 32 | 3. Dispatch — job_types_for_push("code") includes "intel.code.blast_risk" |
| 33 | job_types_for_push("midi") excludes "intel.code.blast_risk" |
| 34 | 4. Tier thresholds — _risk_tier boundaries at 75/50/25 |
| 35 | 5. Score formula — weights sum correctly, all-max → 100, all-zero → 0 |
| 36 | |
| 37 | Integration (DB): |
| 38 | 6. High blast+churn → critical tier |
| 39 | 7. Zero blast → excluded |
| 40 | 8. Untracked kind → excluded |
| 41 | 9. risk_score capped at 100 |
| 42 | 10. Idempotent — run twice, one row per address |
| 43 | 11. Return type — [("intel.code.blast_risk", {"count": N})] |
| 44 | 12. Empty repo → [] |
| 45 | |
| 46 | State integrity: |
| 47 | 13. Re-run updates risk_score in-place (upsert, not duplicate) |
| 48 | 14. Upsert does not touch symbol_intel blast/churn columns |
| 49 | 15. ref column updated to latest ref on each run |
| 50 | |
| 51 | Performance: |
| 52 | 16. 1000 symbol rows processed in < 5 seconds |
| 53 | |
| 54 | No subprocess: |
| 55 | 17. compute() never calls asyncio.create_subprocess_exec |
| 56 | """ |
| 57 | from __future__ import annotations |
| 58 | |
| 59 | import secrets |
| 60 | import time |
| 61 | from unittest.mock import patch |
| 62 | |
| 63 | import pytest |
| 64 | import pytest_asyncio |
| 65 | from sqlalchemy import select |
| 66 | from sqlalchemy.dialects.postgresql import insert as pg_insert |
| 67 | from sqlalchemy.ext.asyncio import AsyncSession |
| 68 | |
| 69 | from muse.core.types import fake_id, long_id |
| 70 | from musehub.db.musehub_intel_models import MusehubIntelBlastRisk, MusehubSymbolIntel |
| 71 | from musehub.types.json_types import JSONObject |
| 72 | from tests.factories import create_repo |
| 73 | |
| 74 | |
| 75 | def _uid() -> str: |
| 76 | return fake_id(secrets.token_hex(16)) |
| 77 | |
| 78 | |
| 79 | _TRACKED_KINDS = ("function", "async_function", "method", "async_method", "class") |
| 80 | |
| 81 | _REF_A = long_id("a" * 64) |
| 82 | _REF_B = long_id("b" * 64) |
| 83 | |
| 84 | |
| 85 | # --------------------------------------------------------------------------- |
| 86 | # Helpers |
| 87 | # --------------------------------------------------------------------------- |
| 88 | |
| 89 | async def _seed_symbol( |
| 90 | session: AsyncSession, |
| 91 | repo_id: str, |
| 92 | *, |
| 93 | address: str, |
| 94 | kind: str = "function", |
| 95 | blast: int = 10, |
| 96 | blast_direct: int = 5, |
| 97 | blast_cross: int = 2, |
| 98 | churn: int = 5, |
| 99 | churn_30d: int = 3, |
| 100 | churn_90d: int = 4, |
| 101 | ) -> None: |
| 102 | stmt = ( |
| 103 | pg_insert(MusehubSymbolIntel) |
| 104 | .values( |
| 105 | repo_id=repo_id, |
| 106 | address=address, |
| 107 | symbol_kind=kind, |
| 108 | blast=blast, |
| 109 | blast_direct=blast_direct, |
| 110 | blast_cross=blast_cross, |
| 111 | churn=churn, |
| 112 | churn_30d=churn_30d, |
| 113 | churn_90d=churn_90d, |
| 114 | author_count=1, |
| 115 | gravity=0.0, |
| 116 | weekly=[0] * 12, |
| 117 | blast_top=[], |
| 118 | ) |
| 119 | .on_conflict_do_update( |
| 120 | index_elements=["repo_id", "address"], |
| 121 | set_={ |
| 122 | "symbol_kind": kind, |
| 123 | "blast": blast, |
| 124 | "blast_direct": blast_direct, |
| 125 | "blast_cross": blast_cross, |
| 126 | "churn": churn, |
| 127 | "churn_30d": churn_30d, |
| 128 | "churn_90d": churn_90d, |
| 129 | }, |
| 130 | ) |
| 131 | ) |
| 132 | await session.execute(stmt) |
| 133 | await session.flush() |
| 134 | |
| 135 | |
| 136 | async def _get_risk_row( |
| 137 | session: AsyncSession, repo_id: str, address: str |
| 138 | ) -> MusehubIntelBlastRisk | None: |
| 139 | result = await session.execute( |
| 140 | select(MusehubIntelBlastRisk).where( |
| 141 | MusehubIntelBlastRisk.repo_id == repo_id, |
| 142 | MusehubIntelBlastRisk.address == address, |
| 143 | ) |
| 144 | ) |
| 145 | return result.scalar_one_or_none() |
| 146 | |
| 147 | |
| 148 | async def _run_provider(session: AsyncSession, repo_id: str, ref: str = _REF_A) -> list[tuple[str, JSONObject]]: |
| 149 | from musehub.services.musehub_intel_providers import BlastRiskProvider |
| 150 | provider = BlastRiskProvider() |
| 151 | return await provider.compute(session, repo_id, ref, {}) |
| 152 | |
| 153 | |
| 154 | # --------------------------------------------------------------------------- |
| 155 | # Layer 1 — Registry |
| 156 | # --------------------------------------------------------------------------- |
| 157 | |
| 158 | class TestBlastRiskRegistry: |
| 159 | |
| 160 | def test_P1_01_blast_risk_in_provider_registry(self) -> None: |
| 161 | from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY |
| 162 | assert "intel.code.blast_risk" in _PROVIDER_REGISTRY |
| 163 | |
| 164 | def test_P1_02_blast_risk_satisfies_intel_provider_protocol(self) -> None: |
| 165 | from musehub.services.musehub_intel_providers import _PROVIDER_REGISTRY, IntelProvider |
| 166 | provider = _PROVIDER_REGISTRY["intel.code.blast_risk"] |
| 167 | assert isinstance(provider, IntelProvider) |
| 168 | |
| 169 | |
| 170 | # --------------------------------------------------------------------------- |
| 171 | # Layer 2 — Dispatch |
| 172 | # --------------------------------------------------------------------------- |
| 173 | |
| 174 | class TestBlastRiskDispatch: |
| 175 | |
| 176 | def test_P1_03_job_types_for_push_code_includes_blast_risk(self) -> None: |
| 177 | from musehub.services.musehub_intel_providers import job_types_for_push |
| 178 | assert "intel.code.blast_risk" in job_types_for_push("code") |
| 179 | |
| 180 | def test_P1_04_job_types_for_push_midi_excludes_blast_risk(self) -> None: |
| 181 | from musehub.services.musehub_intel_providers import job_types_for_push |
| 182 | assert "intel.code.blast_risk" not in job_types_for_push("midi") |
| 183 | |
| 184 | |
| 185 | # --------------------------------------------------------------------------- |
| 186 | # Layer 3 — Tier thresholds (unit, no DB) |
| 187 | # --------------------------------------------------------------------------- |
| 188 | |
| 189 | class TestRiskTierThresholds: |
| 190 | |
| 191 | def test_P1_05_score_75_is_critical(self) -> None: |
| 192 | from musehub.services.musehub_intel_providers import _risk_tier |
| 193 | assert _risk_tier(75) == "critical" |
| 194 | |
| 195 | def test_P1_06_score_100_is_critical(self) -> None: |
| 196 | from musehub.services.musehub_intel_providers import _risk_tier |
| 197 | assert _risk_tier(100) == "critical" |
| 198 | |
| 199 | def test_P1_07_score_50_is_high(self) -> None: |
| 200 | from musehub.services.musehub_intel_providers import _risk_tier |
| 201 | assert _risk_tier(50) == "high" |
| 202 | |
| 203 | def test_P1_08_score_74_is_high(self) -> None: |
| 204 | from musehub.services.musehub_intel_providers import _risk_tier |
| 205 | assert _risk_tier(74) == "high" |
| 206 | |
| 207 | def test_P1_09_score_25_is_medium(self) -> None: |
| 208 | from musehub.services.musehub_intel_providers import _risk_tier |
| 209 | assert _risk_tier(25) == "medium" |
| 210 | |
| 211 | def test_P1_10_score_49_is_medium(self) -> None: |
| 212 | from musehub.services.musehub_intel_providers import _risk_tier |
| 213 | assert _risk_tier(49) == "medium" |
| 214 | |
| 215 | def test_P1_11_score_24_is_low(self) -> None: |
| 216 | from musehub.services.musehub_intel_providers import _risk_tier |
| 217 | assert _risk_tier(24) == "low" |
| 218 | |
| 219 | def test_P1_11b_score_0_is_low(self) -> None: |
| 220 | from musehub.services.musehub_intel_providers import _risk_tier |
| 221 | assert _risk_tier(0) == "low" |
| 222 | |
| 223 | |
| 224 | # --------------------------------------------------------------------------- |
| 225 | # Layer 4 — Score formula (unit, no DB) |
| 226 | # --------------------------------------------------------------------------- |
| 227 | |
| 228 | class TestRiskScoreFormula: |
| 229 | |
| 230 | def test_P1_12_all_max_inputs_yield_100(self) -> None: |
| 231 | from musehub.services.musehub_intel_providers import _compute_risk_score |
| 232 | score = _compute_risk_score( |
| 233 | impact_score=1.0, |
| 234 | churn_score=1.0, |
| 235 | test_gap_score=1.0, |
| 236 | coupling_score=1.0, |
| 237 | ) |
| 238 | assert score == 100 |
| 239 | |
| 240 | def test_P1_13_all_zero_inputs_yield_0(self) -> None: |
| 241 | from musehub.services.musehub_intel_providers import _compute_risk_score |
| 242 | score = _compute_risk_score( |
| 243 | impact_score=0.0, |
| 244 | churn_score=0.0, |
| 245 | test_gap_score=0.0, |
| 246 | coupling_score=0.0, |
| 247 | ) |
| 248 | assert score == 0 |
| 249 | |
| 250 | def test_P1_14_impact_weight_is_40(self) -> None: |
| 251 | from musehub.services.musehub_intel_providers import _compute_risk_score |
| 252 | score = _compute_risk_score( |
| 253 | impact_score=1.0, |
| 254 | churn_score=0.0, |
| 255 | test_gap_score=0.0, |
| 256 | coupling_score=0.0, |
| 257 | ) |
| 258 | assert score == 40 |
| 259 | |
| 260 | def test_P1_15_churn_weight_is_25(self) -> None: |
| 261 | from musehub.services.musehub_intel_providers import _compute_risk_score |
| 262 | score = _compute_risk_score( |
| 263 | impact_score=0.0, |
| 264 | churn_score=1.0, |
| 265 | test_gap_score=0.0, |
| 266 | coupling_score=0.0, |
| 267 | ) |
| 268 | assert score == 25 |
| 269 | |
| 270 | def test_P1_16_test_gap_weight_is_20(self) -> None: |
| 271 | from musehub.services.musehub_intel_providers import _compute_risk_score |
| 272 | score = _compute_risk_score( |
| 273 | impact_score=0.0, |
| 274 | churn_score=0.0, |
| 275 | test_gap_score=1.0, |
| 276 | coupling_score=0.0, |
| 277 | ) |
| 278 | assert score == 20 |
| 279 | |
| 280 | def test_P1_17_coupling_weight_is_15(self) -> None: |
| 281 | from musehub.services.musehub_intel_providers import _compute_risk_score |
| 282 | score = _compute_risk_score( |
| 283 | impact_score=0.0, |
| 284 | churn_score=0.0, |
| 285 | test_gap_score=0.0, |
| 286 | coupling_score=1.0, |
| 287 | ) |
| 288 | assert score == 15 |
| 289 | |
| 290 | |
| 291 | # --------------------------------------------------------------------------- |
| 292 | # Layer 5 — Integration: confidence tiers via DB |
| 293 | # --------------------------------------------------------------------------- |
| 294 | |
| 295 | class TestBlastRiskIntegration: |
| 296 | |
| 297 | @pytest.mark.asyncio |
| 298 | async def test_P1_18_high_blast_high_churn_yields_critical( |
| 299 | self, db_session: AsyncSession |
| 300 | ) -> None: |
| 301 | repo = await create_repo(db_session) |
| 302 | # blast=50 → impact=1.0, churn_30d=20 → churn=1.0 → score=100 → critical |
| 303 | await _seed_symbol( |
| 304 | db_session, repo.repo_id, |
| 305 | address="pkg/a.py::risky_fn", |
| 306 | blast=50, blast_cross=10, churn_30d=20, |
| 307 | ) |
| 308 | await _run_provider(db_session, repo.repo_id) |
| 309 | row = await _get_risk_row(db_session, repo.repo_id, "pkg/a.py::risky_fn") |
| 310 | assert row is not None |
| 311 | assert row.risk == "critical" |
| 312 | |
| 313 | @pytest.mark.asyncio |
| 314 | async def test_P1_19_zero_blast_excluded( |
| 315 | self, db_session: AsyncSession |
| 316 | ) -> None: |
| 317 | repo = await create_repo(db_session) |
| 318 | await _seed_symbol( |
| 319 | db_session, repo.repo_id, |
| 320 | address="pkg/b.py::no_blast", |
| 321 | blast=0, churn_30d=10, |
| 322 | ) |
| 323 | await _run_provider(db_session, repo.repo_id) |
| 324 | row = await _get_risk_row(db_session, repo.repo_id, "pkg/b.py::no_blast") |
| 325 | assert row is None |
| 326 | |
| 327 | @pytest.mark.asyncio |
| 328 | async def test_P1_20_untracked_kind_excluded( |
| 329 | self, db_session: AsyncSession |
| 330 | ) -> None: |
| 331 | repo = await create_repo(db_session) |
| 332 | await _seed_symbol( |
| 333 | db_session, repo.repo_id, |
| 334 | address="pkg/c.py::some_import", |
| 335 | kind="import", |
| 336 | blast=20, churn_30d=5, |
| 337 | ) |
| 338 | await _run_provider(db_session, repo.repo_id) |
| 339 | row = await _get_risk_row(db_session, repo.repo_id, "pkg/c.py::some_import") |
| 340 | assert row is None |
| 341 | |
| 342 | @pytest.mark.asyncio |
| 343 | async def test_P1_21_risk_score_capped_at_100( |
| 344 | self, db_session: AsyncSession |
| 345 | ) -> None: |
| 346 | repo = await create_repo(db_session) |
| 347 | # Extreme inputs — all scores at 1.0 → should be exactly 100, never over |
| 348 | await _seed_symbol( |
| 349 | db_session, repo.repo_id, |
| 350 | address="pkg/d.py::overflow_fn", |
| 351 | blast=9999, blast_cross=9999, churn_30d=9999, |
| 352 | ) |
| 353 | await _run_provider(db_session, repo.repo_id) |
| 354 | row = await _get_risk_row(db_session, repo.repo_id, "pkg/d.py::overflow_fn") |
| 355 | assert row is not None |
| 356 | assert row.risk_score <= 100 |
| 357 | |
| 358 | @pytest.mark.asyncio |
| 359 | async def test_P1_22_idempotent_run_twice_one_row( |
| 360 | self, db_session: AsyncSession |
| 361 | ) -> None: |
| 362 | from sqlalchemy import func |
| 363 | repo = await create_repo(db_session) |
| 364 | await _seed_symbol( |
| 365 | db_session, repo.repo_id, |
| 366 | address="pkg/e.py::idem_fn", |
| 367 | blast=10, churn_30d=5, |
| 368 | ) |
| 369 | await _run_provider(db_session, repo.repo_id) |
| 370 | await _run_provider(db_session, repo.repo_id) |
| 371 | count = (await db_session.execute( |
| 372 | select(func.count()).select_from(MusehubIntelBlastRisk).where( |
| 373 | MusehubIntelBlastRisk.repo_id == repo.repo_id, |
| 374 | MusehubIntelBlastRisk.address == "pkg/e.py::idem_fn", |
| 375 | ) |
| 376 | )).scalar_one() |
| 377 | assert count == 1 |
| 378 | |
| 379 | @pytest.mark.asyncio |
| 380 | async def test_P1_23_return_type( |
| 381 | self, db_session: AsyncSession |
| 382 | ) -> None: |
| 383 | repo = await create_repo(db_session) |
| 384 | await _seed_symbol( |
| 385 | db_session, repo.repo_id, |
| 386 | address="pkg/f.py::ret_fn", |
| 387 | blast=10, churn_30d=3, |
| 388 | ) |
| 389 | result = await _run_provider(db_session, repo.repo_id) |
| 390 | assert len(result) == 1 |
| 391 | intel_type, data = result[0] |
| 392 | assert intel_type == "intel.code.blast_risk" |
| 393 | assert data["count"] == 1 |
| 394 | |
| 395 | @pytest.mark.asyncio |
| 396 | async def test_P1_24_empty_repo_returns_empty_list( |
| 397 | self, db_session: AsyncSession |
| 398 | ) -> None: |
| 399 | repo = await create_repo(db_session) |
| 400 | result = await _run_provider(db_session, repo.repo_id) |
| 401 | assert result == [] |
| 402 | |
| 403 | |
| 404 | # --------------------------------------------------------------------------- |
| 405 | # Layer 6 — State integrity |
| 406 | # --------------------------------------------------------------------------- |
| 407 | |
| 408 | class TestBlastRiskStateIntegrity: |
| 409 | |
| 410 | @pytest.mark.asyncio |
| 411 | async def test_P1_25_rerun_updates_risk_score_in_place( |
| 412 | self, db_session: AsyncSession |
| 413 | ) -> None: |
| 414 | repo = await create_repo(db_session) |
| 415 | repo_id = repo.repo_id # capture before any expire_all invalidates the ORM object |
| 416 | # First run: low churn → lower score |
| 417 | await _seed_symbol(db_session, repo_id, address="pkg/g.py::update_fn", |
| 418 | blast=10, blast_cross=0, churn_30d=0) |
| 419 | await _run_provider(db_session, repo_id) |
| 420 | row_first = await _get_risk_row(db_session, repo_id, "pkg/g.py::update_fn") |
| 421 | score_first = row_first.risk_score |
| 422 | |
| 423 | # Update symbol to high churn — raw SQL upsert, bypasses ORM identity map |
| 424 | await _seed_symbol(db_session, repo_id, address="pkg/g.py::update_fn", |
| 425 | blast=10, blast_cross=0, churn_30d=20) |
| 426 | await _run_provider(db_session, repo_id) |
| 427 | db_session.expire_all() # invalidate cached blast_risk row so _get_risk_row re-fetches |
| 428 | row_second = await _get_risk_row(db_session, repo_id, "pkg/g.py::update_fn") |
| 429 | assert row_second.risk_score > score_first |
| 430 | |
| 431 | # Still one row |
| 432 | from sqlalchemy import func |
| 433 | count = (await db_session.execute( |
| 434 | select(func.count()).select_from(MusehubIntelBlastRisk).where( |
| 435 | MusehubIntelBlastRisk.repo_id == repo_id, |
| 436 | MusehubIntelBlastRisk.address == "pkg/g.py::update_fn", |
| 437 | ) |
| 438 | )).scalar_one() |
| 439 | assert count == 1 |
| 440 | |
| 441 | @pytest.mark.asyncio |
| 442 | async def test_P1_26_upsert_does_not_touch_symbol_intel_blast_churn( |
| 443 | self, db_session: AsyncSession |
| 444 | ) -> None: |
| 445 | repo = await create_repo(db_session) |
| 446 | await _seed_symbol( |
| 447 | db_session, repo.repo_id, |
| 448 | address="pkg/h.py::no_touch_fn", |
| 449 | blast=7, churn=3, churn_30d=2, |
| 450 | ) |
| 451 | await _run_provider(db_session, repo.repo_id) |
| 452 | |
| 453 | intel_row = (await db_session.execute( |
| 454 | select(MusehubSymbolIntel).where( |
| 455 | MusehubSymbolIntel.repo_id == repo.repo_id, |
| 456 | MusehubSymbolIntel.address == "pkg/h.py::no_touch_fn", |
| 457 | ) |
| 458 | )).scalar_one() |
| 459 | assert intel_row.blast == 7 |
| 460 | assert intel_row.churn == 3 |
| 461 | assert intel_row.churn_30d == 2 |
| 462 | |
| 463 | @pytest.mark.asyncio |
| 464 | async def test_P1_27_ref_column_updated_on_rerun( |
| 465 | self, db_session: AsyncSession |
| 466 | ) -> None: |
| 467 | repo = await create_repo(db_session) |
| 468 | repo_id = repo.repo_id # capture before any expire_all invalidates the ORM object |
| 469 | await _seed_symbol(db_session, repo_id, address="pkg/i.py::ref_fn", |
| 470 | blast=10, churn_30d=3) |
| 471 | await _run_provider(db_session, repo_id, ref=_REF_A) |
| 472 | row = await _get_risk_row(db_session, repo_id, "pkg/i.py::ref_fn") |
| 473 | assert row.ref == _REF_A |
| 474 | |
| 475 | await _run_provider(db_session, repo_id, ref=_REF_B) |
| 476 | db_session.expire_all() # invalidate cached blast_risk row so _get_risk_row re-fetches |
| 477 | row = await _get_risk_row(db_session, repo_id, "pkg/i.py::ref_fn") |
| 478 | assert row.ref == _REF_B |
| 479 | |
| 480 | |
| 481 | # --------------------------------------------------------------------------- |
| 482 | # Layer 7 — Performance |
| 483 | # --------------------------------------------------------------------------- |
| 484 | |
| 485 | class TestBlastRiskPerformance: |
| 486 | |
| 487 | @pytest.mark.asyncio |
| 488 | async def test_P1_28_1000_symbols_processed_under_5_seconds( |
| 489 | self, db_session: AsyncSession |
| 490 | ) -> None: |
| 491 | repo = await create_repo(db_session) |
| 492 | # Bulk insert via executemany-style |
| 493 | from sqlalchemy import text |
| 494 | rows = [] |
| 495 | for i in range(1000): |
| 496 | rows.append({ |
| 497 | "repo_id": repo.repo_id, |
| 498 | "address": f"pkg/perf_{i}.py::fn_{i}", |
| 499 | "symbol_kind": _TRACKED_KINDS[i % len(_TRACKED_KINDS)], |
| 500 | "blast": (i % 50) + 1, |
| 501 | "blast_direct": i % 10, |
| 502 | "blast_cross": i % 10, |
| 503 | "churn": (i % 20) + 1, |
| 504 | "churn_30d": i % 20, |
| 505 | "churn_90d": i % 20, |
| 506 | "author_count": 1, |
| 507 | "gravity": 0.0, |
| 508 | "weekly": [0] * 12, |
| 509 | "blast_top": [], |
| 510 | }) |
| 511 | await db_session.execute( |
| 512 | pg_insert(MusehubSymbolIntel) |
| 513 | .values(rows) |
| 514 | .on_conflict_do_nothing() |
| 515 | ) |
| 516 | await db_session.flush() |
| 517 | |
| 518 | start = time.monotonic() |
| 519 | await _run_provider(db_session, repo.repo_id) |
| 520 | elapsed = time.monotonic() - start |
| 521 | assert elapsed < 5.0, f"1000 symbols took {elapsed:.2f}s (limit: 5s)" |
| 522 | |
| 523 | |
| 524 | # --------------------------------------------------------------------------- |
| 525 | # Layer 8 — No subprocess |
| 526 | # --------------------------------------------------------------------------- |
| 527 | |
| 528 | class TestBlastRiskNoSubprocess: |
| 529 | |
| 530 | @pytest.mark.asyncio |
| 531 | async def test_P1_29_no_subprocess_spawned( |
| 532 | self, db_session: AsyncSession |
| 533 | ) -> None: |
| 534 | repo = await create_repo(db_session) |
| 535 | await _seed_symbol( |
| 536 | db_session, repo.repo_id, |
| 537 | address="pkg/j.py::no_proc_fn", |
| 538 | blast=10, churn_30d=3, |
| 539 | ) |
| 540 | with patch("asyncio.create_subprocess_exec") as mock_exec: |
| 541 | await _run_provider(db_session, repo.repo_id) |
| 542 | mock_exec.assert_not_called() |
File History
1 commit
sha256:0997d6250ae6476362f6fe2025af7789f46d03df3e9f34356d5e8ee79b201923
fix(issues): use issue number as pagination cursor, not cre…
Sonnet 4.6
patch
8 days ago