gabriel / musehub public
test_intel_detect_refactor.py python
888 lines 36.5 KB
Raw
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor ⚠ breaking 22 days ago
1 """Detect-Refactor intel — full 7-tier test suite (issue #22).
2
3 Tests are written TDD-first: all tests must be RED before Phase 4–7
4 implementation begins, then GREEN after.
5
6 Tiers
7 -----
8 T01–T05 Layer T1 — DB model (columns, nullable, cascade, index, commit_message)
9 T06–T12 Layer T2 — Provider (no subprocess, impl, sig, move, rename, empty, idempotent)
10 T13–T19 Layer T3 — Route (200, empty state, 404, kind filter, top filter, stat chips, sort)
11 T20–T24 Layer T4 — E2E HTML (kind badges, detail links, stat chips, cycle panel absent, dashboard card)
12 T25–T28 Layer T5 — Data integrity (upsert idempotent, cross-repo isolation, commit_message stored, kind index)
13 T29–T31 Layer T6 — Performance (provider speed, route speed, bulk upsert)
14 T32–T34 Layer T7 — Security (XSS escape in address, SQL injection top param, no 500 on bad kind)
15 """
16 from __future__ import annotations
17
18 import time
19 from datetime import datetime, timezone
20 from unittest.mock import AsyncMock, patch
21
22 import msgpack
23 import pytest
24 import pytest_asyncio
25 import sqlalchemy as sa
26 from httpx import AsyncClient
27 from sqlalchemy.dialects.postgresql import insert as pg_insert
28 from sqlalchemy.ext.asyncio import AsyncSession
29
30 from musehub.db.musehub_intel_models import MusehubIntelRefactorEvent
31 from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubRepo, MusehubSnapshot, MusehubSnapshotRef
32 from musehub.types.json_types import JSONObject
33 from tests.factories import create_repo
34 from muse.core.types import long_id
35
36 _REF = long_id("a" * 64)
37 _SNAP_A = long_id("b" * 64)
38 _SNAP_B = long_id("c" * 64)
39 _CID_A = long_id("d" * 64)
40 _CID_B = long_id("e" * 64)
41 _OBJ_1 = long_id("f" * 64)
42 _OBJ_2 = long_id("1" * 64)
43
44
45 # ─────────────────────────────────────────────────────────────────────────────
46 # Helpers
47 # ─────────────────────────────────────────────────────────────────────────────
48
49 async def _insert_event(
50 session: AsyncSession,
51 repo_id: str,
52 event_id: str,
53 kind: str = "implementation",
54 address: str = "src/foo.py::bar",
55 detail: str | None = None,
56 commit_id: str = _CID_A,
57 commit_message: str | None = "feat: add bar",
58 committed_at: datetime | None = None,
59 ) -> None:
60 """Upsert one row into musehub_intel_refactor_events."""
61 if committed_at is None:
62 committed_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
63 await session.execute(
64 pg_insert(MusehubIntelRefactorEvent)
65 .values(
66 event_id=event_id,
67 repo_id=repo_id,
68 kind=kind,
69 address=address,
70 detail=detail,
71 commit_id=commit_id,
72 commit_message=commit_message,
73 committed_at=committed_at,
74 )
75 .on_conflict_do_update(
76 index_elements=["event_id"],
77 set_={
78 "kind": kind,
79 "address": address,
80 "detail": detail,
81 "commit_id": commit_id,
82 "commit_message": commit_message,
83 "committed_at": committed_at,
84 },
85 )
86 )
87
88
89 async def _seed_two_commits(
90 session: AsyncSession,
91 repo_id: str,
92 head_manifest: dict[str, str],
93 parent_manifest: dict[str, str],
94 owner: str,
95 slug: str,
96 ) -> tuple[str, str]:
97 """Insert parent commit/snapshot then HEAD commit/snapshot.
98
99 Returns ``(head_commit_id, parent_commit_id)``.
100 """
101 # ── parent ────────────────────────────────────────────────────────────────
102 await session.execute(
103 pg_insert(MusehubSnapshot)
104 .values(
105 snapshot_id = _SNAP_B,
106 directories = [],
107 manifest_blob = msgpack.packb(parent_manifest),
108 entry_count = len(parent_manifest),
109 created_at = datetime(2026, 1, 1, tzinfo=timezone.utc),
110 )
111 .on_conflict_do_nothing()
112 )
113 await session.execute(
114 pg_insert(MusehubSnapshotRef)
115 .values(repo_id=repo_id, snapshot_id=_SNAP_B)
116 .on_conflict_do_nothing()
117 )
118 await session.execute(
119 pg_insert(MusehubCommit)
120 .values(
121 commit_id = _CID_B,
122 branch = "dev",
123 parent_ids = [],
124 message = "chore: initial",
125 author = owner,
126 timestamp = datetime(2026, 1, 1, tzinfo=timezone.utc),
127 snapshot_id = _SNAP_B,
128 )
129 .on_conflict_do_nothing()
130 )
131 await session.execute(
132 pg_insert(MusehubCommitRef)
133 .values(repo_id=repo_id, commit_id=_CID_B)
134 .on_conflict_do_nothing()
135 )
136
137 # ── HEAD ──────────────────────────────────────────────────────────────────
138 await session.execute(
139 pg_insert(MusehubSnapshot)
140 .values(
141 snapshot_id = _SNAP_A,
142 directories = [],
143 manifest_blob = msgpack.packb(head_manifest),
144 entry_count = len(head_manifest),
145 created_at = datetime(2026, 1, 2, tzinfo=timezone.utc),
146 )
147 .on_conflict_do_nothing()
148 )
149 await session.execute(
150 pg_insert(MusehubSnapshotRef)
151 .values(repo_id=repo_id, snapshot_id=_SNAP_A)
152 .on_conflict_do_nothing()
153 )
154 await session.execute(
155 pg_insert(MusehubCommit)
156 .values(
157 commit_id = _CID_A,
158 branch = "dev",
159 parent_ids = [_CID_B],
160 message = "feat: refactor things",
161 author = owner,
162 timestamp = datetime(2026, 1, 2, tzinfo=timezone.utc),
163 snapshot_id = _SNAP_A,
164 )
165 .on_conflict_do_nothing()
166 )
167 await session.execute(
168 pg_insert(MusehubCommitRef)
169 .values(repo_id=repo_id, commit_id=_CID_A)
170 .on_conflict_do_nothing()
171 )
172 await session.commit()
173 return _CID_A, _CID_B
174
175
176 def _sym(
177 file_path: str,
178 name: str,
179 body_hash: str,
180 signature_id: str,
181 kind: str = "function",
182 ) -> tuple[str, dict]:
183 """Return ``(address, rec)`` suitable for a parse_symbols side_effect dict."""
184 return f"{file_path}::{name}", {
185 "kind": kind,
186 "name": name,
187 "qualified_name": name,
188 "content_id": long_id("9" * 64),
189 "body_hash": body_hash,
190 "signature_id": signature_id,
191 "metadata_id": "",
192 "canonical_key": f"{file_path}##function#{name}#1",
193 "lineno": 1,
194 "end_lineno": 5,
195 }
196
197
198 @pytest_asyncio.fixture
199 async def rf_repo(db_session: AsyncSession) -> MusehubRepo:
200 """Repo seeded with 5 detect-refactor event rows."""
201 repo = await create_repo(db_session, owner="rfuser", slug="rf-e2e")
202 rid = str(repo.repo_id)
203
204 kinds = [
205 ("implementation", "src/a.py::foo"),
206 ("implementation", "src/b.py::bar"),
207 ("signature", "src/c.py::baz"),
208 ("move", "src/d.py::old_fn"),
209 ("rename", "src/e.py::qux"),
210 ]
211 for i, (kind, addr) in enumerate(kinds):
212 await _insert_event(
213 db_session, rid,
214 event_id=long_id(f"ev{'0' * 60}{i:02d}"),
215 kind=kind,
216 address=addr,
217 commit_id=_CID_A,
218 )
219 await db_session.commit()
220 return repo
221
222
223 # ─────────────────────────────────────────────────────────────────────────────
224 # Layer T1 — DB model
225 # ─────────────────────────────────────────────────────────────────────────────
226
227 class TestDBModel:
228
229 def test_T01_model_has_commit_message_column(self) -> None:
230 """MusehubIntelRefactorEvent must expose a commit_message column."""
231 cols = {c.name for c in MusehubIntelRefactorEvent.__table__.columns}
232 assert "commit_message" in cols
233
234 def test_T02_commit_message_is_nullable(self) -> None:
235 """commit_message must be nullable (older rows pre-migration have NULL)."""
236 col = MusehubIntelRefactorEvent.__table__.columns["commit_message"]
237 assert col.nullable is True
238
239 def test_T03_model_has_required_columns(self) -> None:
240 """event_id, repo_id, kind, address, commit_id, committed_at must exist."""
241 cols = {c.name for c in MusehubIntelRefactorEvent.__table__.columns}
242 for required in ("event_id", "repo_id", "kind", "address", "commit_id", "committed_at"):
243 assert required in cols, f"Column '{required}' missing"
244
245 def test_T04_cascade_delete_configured(self) -> None:
246 """repo_id FK must use CASCADE so repo deletion cleans events."""
247 fks = MusehubIntelRefactorEvent.__table__.foreign_keys
248 for fk in fks:
249 if "repo_id" in str(fk.parent):
250 assert fk.ondelete == "CASCADE"
251 return
252 pytest.fail("No CASCADE FK found for repo_id")
253
254 def test_T05_composite_index_exists(self) -> None:
255 """ix_intel_refactor_events_repo_kind index must cover (repo_id, kind)."""
256 indexes = MusehubIntelRefactorEvent.__table__.indexes
257 names = {idx.name for idx in indexes}
258 assert "ix_intel_refactor_events_repo_kind" in names
259
260
261 # ─────────────────────────────────────────────────────────────────────────────
262 # Layer T2 — Provider
263 # ─────────────────────────────────────────────────────────────────────────────
264
265 class TestProvider:
266
267 @pytest.mark.asyncio
268 async def test_T06_provider_uses_no_subprocess(
269 self, db_session: AsyncSession
270 ) -> None:
271 """DetectRefactorProvider must never call _run_muse or import subprocess."""
272 from musehub.services import musehub_intel_providers as svc
273 import inspect, ast, textwrap
274 src = inspect.getsource(svc.DetectRefactorProvider)
275 # Strip docstrings from AST so comment-only mentions don't trip us up
276 tree = ast.parse(textwrap.dedent(src))
277 non_doc_src = ast.unparse(tree)
278 assert "_run_muse" not in non_doc_src, "DetectRefactorProvider must not call _run_muse"
279 assert "import subprocess" not in non_doc_src, "DetectRefactorProvider must not import subprocess"
280
281 @pytest.mark.asyncio
282 async def test_T07_provider_detects_implementation_change(
283 self, db_session: AsyncSession
284 ) -> None:
285 """Same address, different body_hash → kind='implementation'."""
286 from musehub.services.musehub_intel_providers import DetectRefactorProvider
287
288 repo = await create_repo(db_session, owner="rfp1", slug="rf-impl")
289 rid = str(repo.repo_id)
290
291 manifest = {"src/foo.py": _OBJ_1}
292 await _seed_two_commits(db_session, rid, manifest, manifest, "rfp1", "rf-impl")
293
294 # HEAD has different body_hash, same sig
295 head_tree = dict([_sym("src/foo.py", "bar", long_id("a" * 64), long_id("s" * 64))])
296 parent_tree = dict([_sym("src/foo.py", "bar", long_id("b" * 64), long_id("s" * 64))])
297
298 mock_backend = AsyncMock()
299 mock_backend.get = AsyncMock(side_effect=[b"head", b"parent"])
300
301 with (
302 patch("musehub.services.musehub_intel_providers.get_backend", return_value=mock_backend),
303 patch("musehub.services.musehub_intel_providers.parse_symbols",
304 side_effect=[head_tree, parent_tree, head_tree, parent_tree]),
305 ):
306 results = await DetectRefactorProvider().compute(
307 db_session, rid, _CID_A, {"owner": "rfp1", "slug": "rf-impl"}
308 )
309
310 assert results, "Provider returned no results"
311 count = results[0][1]["count"]
312 assert count == 1, f"Expected 1 implementation event, got {count}"
313
314 row = (await db_session.execute(
315 sa.select(MusehubIntelRefactorEvent)
316 .where(MusehubIntelRefactorEvent.repo_id == rid)
317 )).scalars().first()
318 assert row is not None
319 assert row.kind == "implementation"
320
321 @pytest.mark.asyncio
322 async def test_T08_provider_detects_signature_change(
323 self, db_session: AsyncSession
324 ) -> None:
325 """Same body_hash but different signature_id → kind='signature'."""
326 from musehub.services.musehub_intel_providers import DetectRefactorProvider
327
328 repo = await create_repo(db_session, owner="rfp2", slug="rf-sig")
329 rid = str(repo.repo_id)
330
331 manifest = {"src/foo.py": _OBJ_1}
332 await _seed_two_commits(db_session, rid, manifest, manifest, "rfp2", "rf-sig")
333
334 body_h = long_id("b" * 64)
335 head_tree = dict([_sym("src/foo.py", "bar", body_h, long_id("s1" + "a" * 62))])
336 parent_tree = dict([_sym("src/foo.py", "bar", body_h, long_id("s2" + "a" * 62))])
337
338 mock_backend = AsyncMock()
339 mock_backend.get = AsyncMock(side_effect=[b"head", b"parent"])
340
341 with (
342 patch("musehub.services.musehub_intel_providers.get_backend", return_value=mock_backend),
343 patch("musehub.services.musehub_intel_providers.parse_symbols",
344 side_effect=[head_tree, parent_tree, head_tree, parent_tree]),
345 ):
346 results = await DetectRefactorProvider().compute(
347 db_session, rid, _CID_A, {"owner": "rfp2", "slug": "rf-sig"}
348 )
349
350 count = results[0][1]["count"]
351 assert count == 1, f"Expected 1 signature event, got {count}"
352
353 row = (await db_session.execute(
354 sa.select(MusehubIntelRefactorEvent)
355 .where(MusehubIntelRefactorEvent.repo_id == rid)
356 )).scalars().first()
357 assert row is not None
358 assert row.kind == "signature"
359
360 @pytest.mark.asyncio
361 async def test_T09_provider_detects_move(
362 self, db_session: AsyncSession
363 ) -> None:
364 """Same body_hash at different file path → kind='move'."""
365 from musehub.services.musehub_intel_providers import DetectRefactorProvider
366
367 repo = await create_repo(db_session, owner="rfp3", slug="rf-move")
368 rid = str(repo.repo_id)
369
370 head_manifest = {"src/new.py": _OBJ_1}
371 parent_manifest = {"src/old.py": _OBJ_2}
372 await _seed_two_commits(
373 db_session, rid, head_manifest, parent_manifest, "rfp3", "rf-move"
374 )
375
376 body_h = long_id("b" * 64)
377 sig_h = long_id("s" * 64)
378 head_tree = dict([_sym("src/new.py", "fn", body_h, sig_h)])
379 parent_tree = dict([_sym("src/old.py", "fn", body_h, sig_h)])
380
381 mock_backend = AsyncMock()
382 mock_backend.get = AsyncMock(side_effect=[b"head", b"parent"])
383
384 with (
385 patch("musehub.services.musehub_intel_providers.get_backend", return_value=mock_backend),
386 patch("musehub.services.musehub_intel_providers.parse_symbols",
387 side_effect=[head_tree, parent_tree, head_tree, parent_tree]),
388 ):
389 results = await DetectRefactorProvider().compute(
390 db_session, rid, _CID_A, {"owner": "rfp3", "slug": "rf-move"}
391 )
392
393 count = results[0][1]["count"]
394 assert count == 1, f"Expected 1 move event, got {count}"
395
396 row = (await db_session.execute(
397 sa.select(MusehubIntelRefactorEvent)
398 .where(MusehubIntelRefactorEvent.repo_id == rid)
399 )).scalars().first()
400 assert row is not None
401 assert row.kind == "move"
402 assert row.detail == "src/new.py::fn"
403
404 @pytest.mark.asyncio
405 async def test_T10_provider_detects_rename(
406 self, db_session: AsyncSession
407 ) -> None:
408 """Same body_hash at same file but different name → kind='rename'."""
409 from musehub.services.musehub_intel_providers import DetectRefactorProvider
410
411 repo = await create_repo(db_session, owner="rfp4", slug="rf-rename")
412 rid = str(repo.repo_id)
413
414 manifest = {"src/foo.py": _OBJ_1}
415 await _seed_two_commits(db_session, rid, manifest, manifest, "rfp4", "rf-rename")
416
417 body_h = long_id("b" * 64)
418 sig_h = long_id("s" * 64)
419 head_tree = dict([_sym("src/foo.py", "new_name", body_h, sig_h)])
420 parent_tree = dict([_sym("src/foo.py", "old_name", body_h, sig_h)])
421
422 mock_backend = AsyncMock()
423 mock_backend.get = AsyncMock(side_effect=[b"head", b"parent"])
424
425 with (
426 patch("musehub.services.musehub_intel_providers.get_backend", return_value=mock_backend),
427 patch("musehub.services.musehub_intel_providers.parse_symbols",
428 side_effect=[head_tree, parent_tree, head_tree, parent_tree]),
429 ):
430 results = await DetectRefactorProvider().compute(
431 db_session, rid, _CID_A, {"owner": "rfp4", "slug": "rf-rename"}
432 )
433
434 count = results[0][1]["count"]
435 assert count == 1, f"Expected 1 rename event, got {count}"
436
437 row = (await db_session.execute(
438 sa.select(MusehubIntelRefactorEvent)
439 .where(MusehubIntelRefactorEvent.repo_id == rid)
440 )).scalars().first()
441 assert row is not None
442 assert row.kind == "rename"
443
444 @pytest.mark.asyncio
445 async def test_T11_provider_returns_empty_when_no_parent(
446 self, db_session: AsyncSession
447 ) -> None:
448 """Initial commit (no parent) must produce no events."""
449 from musehub.services.musehub_intel_providers import DetectRefactorProvider
450
451 repo = await create_repo(db_session, owner="rfp5", slug="rf-noparen")
452 rid = str(repo.repo_id)
453
454 snap_id = long_id("f" * 64)
455 await session_insert_snapshot(db_session, rid, snap_id, {"src/a.py": _OBJ_1})
456 await db_session.execute(
457 pg_insert(MusehubCommit)
458 .values(
459 commit_id = _CID_A,
460 branch = "dev",
461 parent_ids = [], # ← no parent
462 message = "init",
463 author = "rfp5",
464 timestamp = datetime(2026, 1, 1, tzinfo=timezone.utc),
465 snapshot_id = snap_id,
466 )
467 .on_conflict_do_nothing()
468 )
469 await db_session.execute(
470 pg_insert(MusehubCommitRef)
471 .values(repo_id=rid, commit_id=_CID_A)
472 .on_conflict_do_nothing()
473 )
474 await db_session.commit()
475
476 results = await DetectRefactorProvider().compute(
477 db_session, rid, _CID_A, {"owner": "rfp5", "slug": "rf-noparen"}
478 )
479 assert results == [], f"Expected [], got {results}"
480
481 @pytest.mark.asyncio
482 async def test_T12_provider_is_idempotent(
483 self, db_session: AsyncSession
484 ) -> None:
485 """Running the provider twice must not create duplicate rows."""
486 from musehub.services.musehub_intel_providers import DetectRefactorProvider
487
488 repo = await create_repo(db_session, owner="rfp6", slug="rf-idem")
489 rid = str(repo.repo_id)
490
491 manifest = {"src/foo.py": _OBJ_1}
492 await _seed_two_commits(db_session, rid, manifest, manifest, "rfp6", "rf-idem")
493
494 body_h = long_id("b" * 64)
495 head_tree = dict([_sym("src/foo.py", "bar", body_h, long_id("x" * 64))])
496 parent_tree = dict([_sym("src/foo.py", "bar", long_id("y" * 64), long_id("x" * 64))])
497
498 mock_backend = AsyncMock()
499 mock_backend.get = AsyncMock(return_value=b"src")
500
501 for _ in range(2):
502 with (
503 patch("musehub.services.musehub_intel_providers.get_backend", return_value=mock_backend),
504 patch("musehub.services.musehub_intel_providers.parse_symbols",
505 side_effect=[head_tree.copy(), parent_tree.copy(),
506 head_tree.copy(), parent_tree.copy()]),
507 ):
508 await DetectRefactorProvider().compute(
509 db_session, rid, _CID_A, {"owner": "rfp6", "slug": "rf-idem"}
510 )
511
512 count = (await db_session.execute(
513 sa.select(sa.func.count()).select_from(MusehubIntelRefactorEvent)
514 .where(MusehubIntelRefactorEvent.repo_id == rid)
515 )).scalar_one()
516 assert count == 1, f"Expected 1 row after 2 runs, got {count}"
517
518
519 # ─────────────────────────────────────────────────────────────────────────────
520 # Layer T3 — Route
521 # ─────────────────────────────────────────────────────────────────────────────
522
523 class TestRoute:
524
525 @pytest.mark.asyncio
526 async def test_T13_refactor_page_returns_200(
527 self, client: AsyncClient, rf_repo: MusehubRepo
528 ) -> None:
529 """GET /rfuser/rf-e2e/intel/refactoring must return HTTP 200."""
530 resp = await client.get("/rfuser/rf-e2e/intel/refactoring")
531 assert resp.status_code == 200, resp.text[:500]
532
533 @pytest.mark.asyncio
534 async def test_T14_refactor_page_empty_state(
535 self, client: AsyncClient, db_session: AsyncSession
536 ) -> None:
537 """Route must render empty state when no event rows exist."""
538 repo = await create_repo(db_session, owner="rfempty", slug="rf-nodata")
539 await db_session.commit()
540 resp = await client.get("/rfempty/rf-nodata/intel/refactoring")
541 assert resp.status_code == 200
542 assert "Push a commit" in resp.text
543
544 @pytest.mark.asyncio
545 async def test_T15_refactor_page_404_for_unknown_repo(
546 self, client: AsyncClient
547 ) -> None:
548 """Route must return 404 for an unknown repo slug."""
549 resp = await client.get("/nobody/nonexistent-repo/intel/refactoring")
550 assert resp.status_code == 404
551
552 @pytest.mark.asyncio
553 async def test_T16_kind_filter_limits_results(
554 self, client: AsyncClient, rf_repo: MusehubRepo
555 ) -> None:
556 """?kind=implementation must only show implementation events."""
557 resp = await client.get("/rfuser/rf-e2e/intel/refactoring?kind=implementation")
558 assert resp.status_code == 200
559 html = resp.text
560 assert "implementation" in html
561
562 @pytest.mark.asyncio
563 async def test_T17_top_filter_limits_results(
564 self, client: AsyncClient, rf_repo: MusehubRepo
565 ) -> None:
566 """?top=2 must limit the event list to 2 rows."""
567 resp = await client.get("/rfuser/rf-e2e/intel/refactoring?top=2")
568 assert resp.status_code == 200
569
570 @pytest.mark.asyncio
571 async def test_T18_stat_chips_present_in_html(
572 self, client: AsyncClient, rf_repo: MusehubRepo
573 ) -> None:
574 """Response must include the total, implementation, and signature counts."""
575 resp = await client.get("/rfuser/rf-e2e/intel/refactoring")
576 html = resp.text
577 assert "rf-stat-val" in html, "Missing stat chip value elements"
578
579 @pytest.mark.asyncio
580 async def test_T19_invalid_top_does_not_500(
581 self, client: AsyncClient, rf_repo: MusehubRepo
582 ) -> None:
583 """?top=GARBAGE must return 200 and fall back to the default top."""
584 resp = await client.get("/rfuser/rf-e2e/intel/refactoring?top=GARBAGE")
585 assert resp.status_code == 200, f"Expected 200 on bad top, got {resp.status_code}"
586
587
588 # ─────────────────────────────────────────────────────────────────────────────
589 # Layer T4 — E2E HTML
590 # ─────────────────────────────────────────────────────────────────────────────
591
592 class TestHTML:
593
594 @pytest.mark.asyncio
595 async def test_T20_kind_badges_appear_in_html(
596 self, client: AsyncClient, rf_repo: MusehubRepo
597 ) -> None:
598 """All four kind values must appear in the rendered HTML."""
599 resp = await client.get("/rfuser/rf-e2e/intel/refactoring")
600 html = resp.text
601 for kind in ("implementation", "signature", "move", "rename"):
602 assert kind in html, f"Kind '{kind}' not found in HTML"
603
604 @pytest.mark.asyncio
605 async def test_T21_address_rendered_in_rows(
606 self, client: AsyncClient, rf_repo: MusehubRepo
607 ) -> None:
608 """Event addresses must appear in the rendered row list."""
609 resp = await client.get("/rfuser/rf-e2e/intel/refactoring")
610 assert "src/a.py" in resp.text
611
612 @pytest.mark.asyncio
613 async def test_T22_stat_chip_values_match_db(
614 self, client: AsyncClient, rf_repo: MusehubRepo
615 ) -> None:
616 """Total stat chip must reflect the 5 seeded events."""
617 resp = await client.get("/rfuser/rf-e2e/intel/refactoring")
618 html = resp.text
619 # 5 seeded events → the total count "5" must appear somewhere
620 assert "5" in html
621
622 @pytest.mark.asyncio
623 async def test_T23_dashboard_link_present(
624 self, client: AsyncClient, rf_repo: MusehubRepo
625 ) -> None:
626 """'← Intel Hub' back link must be present on the refactoring page."""
627 resp = await client.get("/rfuser/rf-e2e/intel/refactoring")
628 assert "Intel Hub" in resp.text
629
630 @pytest.mark.asyncio
631 async def test_T24_dashboard_card_present(
632 self, client: AsyncClient, rf_repo: MusehubRepo
633 ) -> None:
634 """Intel dashboard must show the Detect Refactor card."""
635 resp = await client.get("/rfuser/rf-e2e/intel")
636 assert resp.status_code == 200
637 html = resp.text
638 assert "Detect Refactor" in html or "refactoring" in html
639
640
641 # ─────────────────────────────────────────────────────────────────────────────
642 # Layer T5 — Data integrity
643 # ─────────────────────────────────────────────────────────────────────────────
644
645 class TestDataIntegrity:
646
647 @pytest.mark.asyncio
648 async def test_T25_upsert_is_idempotent(
649 self, db_session: AsyncSession
650 ) -> None:
651 """Inserting the same event_id twice must result in exactly one row."""
652 repo = await create_repo(db_session, owner="rfdi1", slug="rf-upsert")
653 rid = str(repo.repo_id)
654
655 for _ in range(2):
656 await _insert_event(db_session, rid, event_id=long_id("e" * 64))
657 await db_session.commit()
658
659 count = (await db_session.execute(
660 sa.select(sa.func.count()).select_from(MusehubIntelRefactorEvent)
661 .where(MusehubIntelRefactorEvent.repo_id == rid)
662 )).scalar_one()
663 assert count == 1
664
665 @pytest.mark.asyncio
666 async def test_T26_cross_repo_isolation(
667 self, db_session: AsyncSession
668 ) -> None:
669 """Events from different repos must not leak into each other's results."""
670 repo_a = await create_repo(db_session, owner="rfdi2a", slug="rf-iso-a")
671 repo_b = await create_repo(db_session, owner="rfdi2b", slug="rf-iso-b")
672 rid_a, rid_b = str(repo_a.repo_id), str(repo_b.repo_id)
673
674 await _insert_event(db_session, rid_a, event_id=long_id("a" * 64))
675 await _insert_event(db_session, rid_b, event_id=long_id("b" * 64))
676 await db_session.commit()
677
678 count_a = (await db_session.execute(
679 sa.select(sa.func.count()).select_from(MusehubIntelRefactorEvent)
680 .where(MusehubIntelRefactorEvent.repo_id == rid_a)
681 )).scalar_one()
682 count_b = (await db_session.execute(
683 sa.select(sa.func.count()).select_from(MusehubIntelRefactorEvent)
684 .where(MusehubIntelRefactorEvent.repo_id == rid_b)
685 )).scalar_one()
686
687 assert count_a == 1
688 assert count_b == 1
689
690 @pytest.mark.asyncio
691 async def test_T27_commit_message_stored(
692 self, db_session: AsyncSession
693 ) -> None:
694 """commit_message must be persisted and queryable."""
695 repo = await create_repo(db_session, owner="rfdi3", slug="rf-msg")
696 rid = str(repo.repo_id)
697
698 await _insert_event(
699 db_session, rid,
700 event_id=long_id("m" * 64),
701 commit_message="feat: spectacular refactor",
702 )
703 await db_session.commit()
704
705 row = (await db_session.execute(
706 sa.select(MusehubIntelRefactorEvent)
707 .where(MusehubIntelRefactorEvent.repo_id == rid)
708 )).scalars().first()
709 assert row is not None
710 assert row.commit_message == "feat: spectacular refactor"
711
712 @pytest.mark.asyncio
713 async def test_T28_cascade_delete_removes_events(
714 self, db_session: AsyncSession
715 ) -> None:
716 """Deleting the repo must cascade-delete all its refactoring events."""
717 repo = await create_repo(db_session, owner="rfdi4", slug="rf-cascade")
718 rid = str(repo.repo_id)
719
720 await _insert_event(db_session, rid, event_id=long_id("z" * 64))
721 await db_session.commit()
722
723 await db_session.delete(repo)
724 await db_session.commit()
725
726 count = (await db_session.execute(
727 sa.select(sa.func.count()).select_from(MusehubIntelRefactorEvent)
728 .where(MusehubIntelRefactorEvent.repo_id == rid)
729 )).scalar_one()
730 assert count == 0
731
732
733 # ─────────────────────────────────────────────────────────────────────────────
734 # Layer T6 — Performance
735 # ─────────────────────────────────────────────────────────────────────────────
736
737 class TestPerformance:
738
739 @pytest.mark.asyncio
740 async def test_T29_provider_completes_under_5s(
741 self, db_session: AsyncSession
742 ) -> None:
743 """Provider must finish under 5 seconds for a 200-symbol diff."""
744 from musehub.services.musehub_intel_providers import DetectRefactorProvider
745
746 repo = await create_repo(db_session, owner="rfperf1", slug="rf-perf")
747 rid = str(repo.repo_id)
748
749 manifest = {f"src/file_{i}.py": long_id(f"{'0' * 63}{i}") for i in range(10)}
750 await _seed_two_commits(db_session, rid, manifest, manifest, "rfperf1", "rf-perf")
751
752 def _make_tree(prefix: str) -> JSONObject:
753 tree = {}
754 for i in range(20):
755 addr, rec = _sym(
756 f"src/file_{i % 10}.py", f"fn_{i}",
757 long_id(prefix * 64),
758 long_id("s" * 64),
759 )
760 tree[addr] = rec
761 return tree
762
763 head_tree = _make_tree("a")
764 parent_tree = _make_tree("b")
765
766 mock_backend = AsyncMock()
767 mock_backend.get = AsyncMock(return_value=b"src")
768
769 t0 = time.monotonic()
770 with (
771 patch("musehub.services.musehub_intel_providers.get_backend", return_value=mock_backend),
772 patch("musehub.services.musehub_intel_providers.parse_symbols",
773 side_effect=[head_tree.copy(), parent_tree.copy()] * 20),
774 ):
775 await DetectRefactorProvider().compute(
776 db_session, rid, _CID_A, {"owner": "rfperf1", "slug": "rf-perf"}
777 )
778 elapsed = time.monotonic() - t0
779 assert elapsed < 5.0, f"Provider took {elapsed:.2f}s — exceeds 5s budget"
780
781 @pytest.mark.asyncio
782 async def test_T30_route_responds_under_500ms(
783 self, client: AsyncClient, rf_repo: MusehubRepo
784 ) -> None:
785 """Refactoring page must respond in under 500 ms with 5 seeded rows."""
786 t0 = time.monotonic()
787 resp = await client.get("/rfuser/rf-e2e/intel/refactoring")
788 elapsed = (time.monotonic() - t0) * 1000
789 assert resp.status_code == 200
790 assert elapsed < 500, f"Route took {elapsed:.0f}ms — exceeds 500ms budget"
791
792 @pytest.mark.asyncio
793 async def test_T31_bulk_insert_500_events(
794 self, db_session: AsyncSession
795 ) -> None:
796 """Inserting 500 events must complete in under 10 seconds."""
797 repo = await create_repo(db_session, owner="rfperf2", slug="rf-bulk")
798 rid = str(repo.repo_id)
799
800 t0 = time.monotonic()
801 for i in range(500):
802 await _insert_event(
803 db_session, rid,
804 event_id=long_id(f"{'0' * 60}{i:04d}"),
805 address=f"src/f{i}.py::fn",
806 )
807 await db_session.commit()
808 elapsed = time.monotonic() - t0
809 assert elapsed < 10.0, f"500-row insert took {elapsed:.2f}s"
810
811 count = (await db_session.execute(
812 sa.select(sa.func.count()).select_from(MusehubIntelRefactorEvent)
813 .where(MusehubIntelRefactorEvent.repo_id == rid)
814 )).scalar_one()
815 assert count == 500
816
817
818 # ─────────────────────────────────────────────────────────────────────────────
819 # Layer T7 — Security
820 # ─────────────────────────────────────────────────────────────────────────────
821
822 class TestSecurity:
823
824 @pytest.mark.asyncio
825 async def test_T32_xss_in_address_is_escaped(
826 self, client: AsyncClient, db_session: AsyncSession
827 ) -> None:
828 """address containing HTML must be escaped — raw tags must not appear."""
829 repo = await create_repo(db_session, owner="rfxss", slug="rf-xss")
830 rid = str(repo.repo_id)
831
832 await _insert_event(
833 db_session, rid,
834 event_id=long_id("x" * 64),
835 address='src/<script>alert(1)</script>.py::fn',
836 )
837 await db_session.commit()
838
839 resp = await client.get("/rfxss/rf-xss/intel/refactoring")
840 assert resp.status_code == 200
841 assert "<script>alert(1)</script>" not in resp.text
842
843 @pytest.mark.asyncio
844 async def test_T33_sql_injection_in_top_param_returns_200(
845 self, client: AsyncClient, rf_repo: MusehubRepo
846 ) -> None:
847 """?top=1;DROP TABLE must return 200 and fall back to default top."""
848 resp = await client.get(
849 "/rfuser/rf-e2e/intel/refactoring",
850 params={"top": "1;DROP TABLE musehub_intel_refactor_events;--"},
851 )
852 assert resp.status_code == 200
853
854 @pytest.mark.asyncio
855 async def test_T34_unknown_kind_filter_returns_200(
856 self, client: AsyncClient, rf_repo: MusehubRepo
857 ) -> None:
858 """?kind=INVALID must return 200 with an empty or full result set."""
859 resp = await client.get("/rfuser/rf-e2e/intel/refactoring?kind=INVALID")
860 assert resp.status_code == 200
861
862
863 # ─────────────────────────────────────────────────────────────────────────────
864 # Helpers used by T11 only
865 # ─────────────────────────────────────────────────────────────────────────────
866
867 async def session_insert_snapshot(
868 session: AsyncSession,
869 repo_id: str,
870 snap_id: str,
871 manifest: dict[str, str],
872 ) -> None:
873 await session.execute(
874 pg_insert(MusehubSnapshot)
875 .values(
876 snapshot_id = snap_id,
877 directories = [],
878 manifest_blob = msgpack.packb(manifest),
879 entry_count = len(manifest),
880 created_at = datetime(2026, 1, 1, tzinfo=timezone.utc),
881 )
882 .on_conflict_do_nothing()
883 )
884 await session.execute(
885 pg_insert(MusehubSnapshotRef)
886 .values(repo_id=repo_id, snapshot_id=snap_id)
887 .on_conflict_do_nothing()
888 )
File History 1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 22 days ago