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