gabriel / musehub public
test_snapshot_symbol_indexer.py python
999 lines 42.1 KB
Raw
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ breaking 143 days ago
1 """Tests for the Snapshot & Symbol Indexer — Section 5 of test-coverage-checklist.md.
2
3 Complements test_snapshot_entries.py (14 tests on the snapshot write/read path).
4 This file focuses on the symbol indexer and the gaps not covered there.
5
6 Coverage layers
7 ───────────────
8 Unit — _extract_ops (flat/nested child_ops, missing address, non-dict delta);
9 _op_to_muse_op (all mapping keys, unknown passthrough).
10 Integration — build_symbol_index: empty list when no structured_delta; returns results
11 for repos with structured_delta; correct symbol_history/hash_occurrence
12 content; upsert semantics (only one row per repo/intel_type); BFS
13 excludes orphaned commits.
14 load_symbol_history: empty when no index; with/without file_path filter.
15 load_hash_occurrence: empty when no index; correct content.
16 get_index_meta: None/present states.
17 load_intel_snapshot: None/present states.
18 get_snapshot_manifests_batch: empty list, single, multi-snapshot.
19 Data — upsert_snapshot_entries atomic replace (stale entries removed);
20 build_symbol_index + persist_intel_results upserts on rebuild;
21 BFS reachability excludes orphaned branches.
22 Security — Corrupt JSON blob returns {} not exception;
23 build_symbol_index with unknown head_commit_id returns empty list.
24 Stress — upsert_snapshot_entries with 1 000-file manifest;
25 get_snapshot_manifests_batch with 50 snapshots in one query;
26 build_symbol_index with 100 commits (10 ops each);
27 load_symbol_history file_path filter on large index.
28 Performance — _extract_ops 1 000 calls < 100 ms;
29 build_symbol_index 100 commits < 3 s.
30 E2E — Full pipeline: commits with structured_delta → build_symbol_index →
31 persist_intel_results → get_index_meta returns correct ref;
32 rebuild replaces previous result; symbol list HTTP page returns 200.
33 """
34 from __future__ import annotations
35
36 import json
37 import time
38 import uuid
39 from datetime import datetime, timezone
40
41 import pytest
42 from sqlalchemy.ext.asyncio import AsyncSession
43
44 from musehub.db import musehub_models as db
45 from tests.factories import create_repo
46 from musehub.types.json_types import JSONObject
47
48
49 # ─────────────────────────────────────────────────────────────────────────────
50 # Helpers
51 # ─────────────────────────────────────────────────────────────────────────────
52
53 def _now() -> datetime:
54 return datetime.now(tz=timezone.utc)
55
56
57 async def _commit_with_delta(
58 session: AsyncSession,
59 repo_id: str,
60 commit_id: str,
61 ops: list[JSONObject],
62 parent_ids: list[str] | None = None,
63 branch: str = "main",
64 author: str = "gabriel",
65 ) -> db.MusehubCommit:
66 """Insert a commit whose commit_meta carries a structured_delta."""
67 commit = db.MusehubCommit(
68 commit_id=commit_id,
69 repo_id=repo_id,
70 branch=branch,
71 parent_ids=parent_ids or [],
72 message="feat: test commit",
73 author=author,
74 timestamp=_now(),
75 commit_meta={"structured_delta": {"ops": ops}},
76 )
77 session.add(commit)
78 await session.flush()
79 return commit
80
81
82 def _insert_op(address: str, content_id: str = "sha256:abc") -> JSONObject:
83 return {"address": address, "op": "insert", "content_id": content_id}
84
85
86 def _patch_op(file_addr: str, children: list[JSONObject]) -> JSONObject:
87 return {"address": file_addr, "op": "patch", "child_ops": children}
88
89
90 async def _build_and_persist(
91 session: AsyncSession,
92 repo_id: str,
93 commit_id: str,
94 ) -> list[tuple[str, dict]]:
95 """Build symbol index and persist results; returns the result list."""
96 from musehub.services.musehub_symbol_indexer import build_symbol_index
97 from musehub.services.musehub_intel_providers import persist_intel_results
98 results = await build_symbol_index(session, repo_id, commit_id)
99 if results:
100 await persist_intel_results(session, repo_id, commit_id, results)
101 return results
102
103
104 def _get_result_data(results: list[tuple[str, JSONObject]], intel_type: str) -> JSONObject:
105 """Extract data dict for a specific intel_type from the results list."""
106 for t, data in results:
107 if t == intel_type:
108 return data
109 return {}
110
111
112 # ─────────────────────────────────────────────────────────────────────────────
113 # Layer 1 — Unit: pure functions
114 # ─────────────────────────────────────────────────────────────────────────────
115
116 class TestExtractOps:
117 """_extract_ops pulls a flat list of ops including child_ops."""
118
119 def _run(self, commit_meta: JSONObject) -> list[JSONObject]:
120 from musehub.services.musehub_symbol_indexer import _extract_ops
121 return _extract_ops(commit_meta)
122
123 def test_no_structured_delta_returns_empty(self) -> None:
124 assert self._run({}) == []
125
126 def test_none_delta_returns_empty(self) -> None:
127 assert self._run({"structured_delta": None}) == []
128
129 def test_non_dict_delta_returns_empty(self) -> None:
130 assert self._run({"structured_delta": "bad"}) == []
131
132 def test_flat_ops_without_child_ops(self) -> None:
133 meta = {
134 "structured_delta": {
135 "ops": [
136 {"address": "main.py::Foo", "op": "insert"},
137 {"address": "main.py::Bar", "op": "delete"},
138 ]
139 }
140 }
141 result = self._run(meta)
142 assert len(result) == 2
143 assert result[0]["address"] == "main.py::Foo"
144 assert result[1]["address"] == "main.py::Bar"
145
146 def test_patch_op_with_child_ops_flattened(self) -> None:
147 meta = {
148 "structured_delta": {
149 "ops": [
150 {
151 "address": "src/app.py",
152 "op": "patch",
153 "child_ops": [
154 {"address": "src/app.py::MyClass", "op": "insert"},
155 {"address": "src/app.py::MyClass.run", "op": "insert"},
156 ],
157 }
158 ]
159 }
160 }
161 result = self._run(meta)
162 # 1 top-level + 2 child_ops
163 assert len(result) == 3
164 addresses = [op["address"] for op in result]
165 assert "src/app.py" in addresses
166 assert "src/app.py::MyClass" in addresses
167 assert "src/app.py::MyClass.run" in addresses
168
169 def test_op_without_address_skipped(self) -> None:
170 meta = {
171 "structured_delta": {
172 "ops": [
173 {"op": "insert"}, # no address
174 {"address": "ok.py", "op": "insert"},
175 ]
176 }
177 }
178 result = self._run(meta)
179 assert len(result) == 1
180 assert result[0]["address"] == "ok.py"
181
182 def test_child_op_without_address_skipped(self) -> None:
183 meta = {
184 "structured_delta": {
185 "ops": [
186 {
187 "address": "file.py",
188 "op": "patch",
189 "child_ops": [
190 {"op": "insert"}, # no address — must be skipped
191 {"address": "file.py::Good", "op": "insert"},
192 ],
193 }
194 ]
195 }
196 }
197 result = self._run(meta)
198 addresses = [op["address"] for op in result]
199 assert "file.py::Good" in addresses
200 for op in result:
201 assert "address" in op
202
203 def test_non_dict_op_skipped(self) -> None:
204 meta = {"structured_delta": {"ops": ["not-a-dict", {"address": "f.py", "op": "add"}]}}
205 result = self._run(meta)
206 assert len(result) == 1
207
208
209 class TestOpToMuseOp:
210 """_op_to_muse_op maps DomainOp vocabulary to muse history vocabulary."""
211
212 def _run(self, op_type: str) -> str:
213 from musehub.services.musehub_symbol_indexer import _op_to_muse_op
214 return _op_to_muse_op(op_type)
215
216 def test_insert_maps_to_add(self) -> None:
217 assert self._run("insert") == "add"
218
219 def test_delete_maps_to_delete(self) -> None:
220 assert self._run("delete") == "delete"
221
222 def test_replace_maps_to_modify(self) -> None:
223 assert self._run("replace") == "modify"
224
225 def test_patch_maps_to_modify(self) -> None:
226 assert self._run("patch") == "modify"
227
228 def test_move_maps_to_move(self) -> None:
229 assert self._run("move") == "move"
230
231 def test_directory_rename_maps_to_rename(self) -> None:
232 assert self._run("directory_rename") == "rename"
233
234 def test_unknown_op_passthrough(self) -> None:
235 assert self._run("frobnicate") == "frobnicate"
236
237 def test_empty_string_passthrough(self) -> None:
238 assert self._run("") == ""
239
240
241 # ─────────────────────────────────────────────────────────────────────────────
242 # Layer 2 — Integration: build_symbol_index + read functions
243 # ─────────────────────────────────────────────────────────────────────────────
244
245 class TestBuildSymbolIndex:
246 @pytest.mark.asyncio
247 async def test_returns_empty_when_no_structured_delta(
248 self, db_session: AsyncSession
249 ) -> None:
250 from musehub.services.musehub_symbol_indexer import build_symbol_index
251 from tests.factories import create_commit
252
253 repo = await create_repo(db_session, slug="idx-nodelta")
254 commit = await create_commit(db_session, repo.repo_id, branch="main")
255
256 results = await build_symbol_index(db_session, repo.repo_id, commit.commit_id)
257 assert results == []
258
259 @pytest.mark.asyncio
260 async def test_returns_results_for_structured_delta(
261 self, db_session: AsyncSession
262 ) -> None:
263 from musehub.services.musehub_symbol_indexer import load_symbol_history
264 repo = await create_repo(db_session, slug="idx-creates")
265 commit = await _commit_with_delta(
266 db_session, repo.repo_id, "c001",
267 ops=[_insert_op("main.py::Foo", "sha256:aaa")],
268 )
269
270 results = await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
271 await db_session.commit()
272
273 assert results
274 types = {t for t, _ in results}
275 # Aggregate blobs are still produced.
276 assert "code.intel_summary" in types
277 assert "code.intel_snapshot" in types
278 # Per-symbol data now lives in normalized tables, not in blobs.
279 assert "code.symbol_history" not in types
280 assert "code.hash_occurrence" not in types
281 assert "code.per_symbol_intel" not in types
282 # Confirm normalized rows were written.
283 history = await load_symbol_history(db_session, repo.repo_id)
284 assert "main.py::Foo" in history
285
286 @pytest.mark.asyncio
287 async def test_symbol_history_contains_correct_entries(
288 self, db_session: AsyncSession
289 ) -> None:
290 from musehub.services.musehub_symbol_indexer import load_symbol_history
291 repo = await create_repo(db_session, slug="idx-symhist")
292 commit = await _commit_with_delta(
293 db_session, repo.repo_id, "c002",
294 ops=[
295 _insert_op("src/app.py::MyClass", "sha256:class"),
296 _insert_op("src/app.py::my_func", "sha256:func"),
297 ],
298 )
299
300 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
301 await db_session.commit()
302
303 entries = await load_symbol_history(db_session, repo.repo_id)
304 assert "src/app.py::MyClass" in entries
305 assert "src/app.py::my_func" in entries
306 assert entries["src/app.py::MyClass"][0]["op"] == "add"
307
308 @pytest.mark.asyncio
309 async def test_hash_occurrence_tracks_shared_content(
310 self, db_session: AsyncSession
311 ) -> None:
312 repo = await create_repo(db_session, slug="idx-hashoc")
313 shared_hash = "sha256:shared"
314 commit = await _commit_with_delta(
315 db_session, repo.repo_id, "c003",
316 ops=[
317 _insert_op("a.py::Foo", shared_hash),
318 _insert_op("b.py::Bar", shared_hash),
319 ],
320 )
321
322 from musehub.services.musehub_symbol_indexer import load_hash_occurrence
323 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
324 await db_session.commit()
325
326 entries = await load_hash_occurrence(db_session, repo.repo_id)
327 assert shared_hash in entries
328 assert set(entries[shared_hash]) == {"a.py::Foo", "b.py::Bar"}
329
330 @pytest.mark.asyncio
331 async def test_rebuild_upserts_one_row_per_intel_type(
332 self, db_session: AsyncSession
333 ) -> None:
334 from sqlalchemy import select, func
335
336 repo = await create_repo(db_session, slug="idx-prune")
337 c1 = await _commit_with_delta(db_session, repo.repo_id, "c100",
338 ops=[_insert_op("f.py::A")])
339 await _build_and_persist(db_session, repo.repo_id, c1.commit_id)
340 await db_session.commit()
341
342 c2 = await _commit_with_delta(db_session, repo.repo_id, "c101",
343 ops=[_insert_op("f.py::B")])
344 await _build_and_persist(db_session, repo.repo_id, c2.commit_id)
345 await db_session.commit()
346
347 # code.symbol_history is no longer a blob — it lives in normalized rows.
348 # intel_summary/intel_snapshot are the only blobs, each upserted once.
349 blob_count = (await db_session.execute(
350 select(func.count()).select_from(db.MusehubIntelResult).where(
351 db.MusehubIntelResult.repo_id == repo.repo_id,
352 db.MusehubIntelResult.intel_type == "code.intel_summary",
353 )
354 )).scalar_one()
355 assert blob_count == 1
356
357 @pytest.mark.asyncio
358 async def test_bfs_excludes_orphaned_commits(
359 self, db_session: AsyncSession
360 ) -> None:
361 """Commits not reachable from head must not appear in the symbol index."""
362 from musehub.services.musehub_symbol_indexer import load_symbol_history
363
364 repo = await create_repo(db_session, slug="idx-bfs")
365 await _commit_with_delta(
366 db_session, repo.repo_id, "orphan",
367 ops=[_insert_op("orphan.py::OrphanSym", "sha256:orphan")],
368 parent_ids=[],
369 )
370 head = await _commit_with_delta(
371 db_session, repo.repo_id, "head",
372 ops=[_insert_op("main.py::RealSym", "sha256:real")],
373 parent_ids=[],
374 )
375
376 await _build_and_persist(db_session, repo.repo_id, head.commit_id)
377 await db_session.commit()
378
379 history = await load_symbol_history(db_session, repo.repo_id)
380 assert "main.py::RealSym" in history
381 assert "orphan.py::OrphanSym" not in history
382
383
384 class TestLoadFunctions:
385 @pytest.mark.asyncio
386 async def test_load_symbol_history_empty_when_no_index(
387 self, db_session: AsyncSession
388 ) -> None:
389 from musehub.services.musehub_symbol_indexer import load_symbol_history
390 repo = await create_repo(db_session, slug="load-noindex")
391 result = await load_symbol_history(db_session, repo.repo_id)
392 assert result == {}
393
394 @pytest.mark.asyncio
395 async def test_load_symbol_history_with_file_path_filter(
396 self, db_session: AsyncSession
397 ) -> None:
398 from musehub.services.musehub_symbol_indexer import load_symbol_history
399
400 repo = await create_repo(db_session, slug="load-filter")
401 commit = await _commit_with_delta(
402 db_session, repo.repo_id, "cF01",
403 ops=[
404 _insert_op("a.py::Foo", "sha256:x"),
405 _insert_op("a.py", "sha256:file"),
406 _insert_op("b.py::Bar", "sha256:y"),
407 ],
408 )
409 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
410 await db_session.commit()
411
412 result = await load_symbol_history(db_session, repo.repo_id, file_path="a.py")
413 assert "a.py::Foo" in result
414 assert "a.py" in result
415 assert "b.py::Bar" not in result
416
417 @pytest.mark.asyncio
418 async def test_load_hash_occurrence_empty_when_no_index(
419 self, db_session: AsyncSession
420 ) -> None:
421 from musehub.services.musehub_symbol_indexer import load_hash_occurrence
422 repo = await create_repo(db_session, slug="hash-noindex")
423 assert await load_hash_occurrence(db_session, repo.repo_id) == {}
424
425 @pytest.mark.asyncio
426 async def test_load_hash_occurrence_returns_correct_entries(
427 self, db_session: AsyncSession
428 ) -> None:
429 from musehub.services.musehub_symbol_indexer import load_hash_occurrence
430
431 repo = await create_repo(db_session, slug="hash-entries")
432 commit = await _commit_with_delta(
433 db_session, repo.repo_id, "cH01",
434 ops=[_insert_op("x.py::X", "sha256:hash1"), _insert_op("y.py::Y", "sha256:hash1")],
435 )
436 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
437 await db_session.commit()
438
439 result = await load_hash_occurrence(db_session, repo.repo_id)
440 assert "sha256:hash1" in result
441 assert set(result["sha256:hash1"]) == {"x.py::X", "y.py::Y"}
442
443 @pytest.mark.asyncio
444 async def test_get_index_meta_none_when_no_index(
445 self, db_session: AsyncSession
446 ) -> None:
447 from musehub.services.musehub_symbol_indexer import get_index_meta
448 repo = await create_repo(db_session, slug="meta-none")
449 assert await get_index_meta(db_session, repo.repo_id) is None
450
451 @pytest.mark.asyncio
452 async def test_get_index_meta_returns_ref_and_symbol_count(
453 self, db_session: AsyncSession
454 ) -> None:
455 from musehub.services.musehub_symbol_indexer import get_index_meta
456
457 repo = await create_repo(db_session, slug="meta-ok")
458 commit = await _commit_with_delta(
459 db_session, repo.repo_id, "cM01",
460 ops=[_insert_op("f.py::A"), _insert_op("f.py::B")],
461 )
462 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
463 await db_session.commit()
464
465 meta = await get_index_meta(db_session, repo.repo_id)
466 assert meta is not None
467 assert meta["ref"] == commit.commit_id
468 assert meta["built_at"] is not None
469 assert meta["symbol_count"] >= 2
470
471 @pytest.mark.asyncio
472 async def test_load_intel_snapshot_none_when_no_index(
473 self, db_session: AsyncSession
474 ) -> None:
475 from musehub.services.musehub_symbol_indexer import load_intel_snapshot
476 repo = await create_repo(db_session, slug="intel-none")
477 assert await load_intel_snapshot(db_session, repo.repo_id) is None
478
479 @pytest.mark.asyncio
480 async def test_load_intel_snapshot_returns_snapshot_when_built(
481 self, db_session: AsyncSession
482 ) -> None:
483 from musehub.services.musehub_symbol_indexer import load_intel_snapshot
484
485 repo = await create_repo(db_session, slug="intel-ok")
486 commit = await _commit_with_delta(
487 db_session, repo.repo_id, "cI01",
488 ops=[_insert_op("app.py::Handler", "sha256:h1")],
489 )
490 results = await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
491 await db_session.commit()
492
493 assert results, "build_symbol_index returned empty results"
494 assert any(t == "code.intel_snapshot" for t, _ in results), "code.intel_snapshot not in results"
495 snap = await load_intel_snapshot(db_session, repo.repo_id)
496 assert snap is not None
497
498
499 class TestGetSnapshotManifestsBatch:
500 @pytest.mark.asyncio
501 async def test_empty_list_returns_empty_dict(
502 self, db_session: AsyncSession
503 ) -> None:
504 from musehub.services.musehub_snapshot import get_snapshot_manifests_batch
505 result = await get_snapshot_manifests_batch(db_session, [])
506 assert result == {}
507
508 @pytest.mark.asyncio
509 async def test_single_snapshot_manifest(
510 self, db_session: AsyncSession
511 ) -> None:
512 from musehub.services.musehub_snapshot import (
513 get_snapshot_manifests_batch,
514 upsert_snapshot_entries,
515 )
516 repo = await create_repo(db_session, slug="batch-single")
517 snap_id = "snap-batch-01"
518 await upsert_snapshot_entries(
519 db_session, repo.repo_id, snap_id, {"a.py": "sha256:a", "b.py": "sha256:b"}
520 )
521 await db_session.commit()
522
523 result = await get_snapshot_manifests_batch(db_session, [snap_id])
524 assert snap_id in result
525 assert result[snap_id]["a.py"] == "sha256:a"
526 assert result[snap_id]["b.py"] == "sha256:b"
527
528 @pytest.mark.asyncio
529 async def test_multiple_snapshots_grouped_correctly(
530 self, db_session: AsyncSession
531 ) -> None:
532 from musehub.services.musehub_snapshot import (
533 get_snapshot_manifests_batch,
534 upsert_snapshot_entries,
535 )
536 repo = await create_repo(db_session, slug="batch-multi")
537 for i in range(5):
538 snap_id = f"snap-multi-{i:02d}"
539 await upsert_snapshot_entries(
540 db_session, repo.repo_id, snap_id, {f"file{i}.py": f"sha256:{i}"}
541 )
542 await db_session.commit()
543
544 ids = [f"snap-multi-{i:02d}" for i in range(5)]
545 result = await get_snapshot_manifests_batch(db_session, ids)
546 assert len(result) == 5
547 for i, sid in enumerate(ids):
548 assert f"file{i}.py" in result[sid]
549
550 @pytest.mark.asyncio
551 async def test_unknown_snapshot_id_returns_empty_manifest(
552 self, db_session: AsyncSession
553 ) -> None:
554 from musehub.services.musehub_snapshot import get_snapshot_manifests_batch
555 result = await get_snapshot_manifests_batch(db_session, ["ghost-snap"])
556 assert result == {"ghost-snap": {}}
557
558
559 # ─────────────────────────────────────────────────────────────────────────────
560 # Layer 3 — E2E: full pipeline via direct service calls with real DB
561 # ─────────────────────────────────────────────────────────────────────────────
562
563 class TestSymbolIndexPipeline:
564 @pytest.mark.asyncio
565 async def test_build_then_meta_reflects_head_commit(
566 self, db_session: AsyncSession
567 ) -> None:
568 from musehub.services.musehub_symbol_indexer import get_index_meta
569
570 repo = await create_repo(db_session, slug="e2e-pipeline")
571 c1 = await _commit_with_delta(
572 db_session, repo.repo_id, "pipe-c001",
573 ops=[_insert_op("service.py::APIHandler", "sha256:h1")],
574 )
575 await _build_and_persist(db_session, repo.repo_id, c1.commit_id)
576 await db_session.commit()
577
578 meta = await get_index_meta(db_session, repo.repo_id)
579 assert meta is not None
580 assert meta["ref"] == c1.commit_id
581
582 @pytest.mark.asyncio
583 async def test_rebuild_updates_ref_to_latest_commit(
584 self, db_session: AsyncSession
585 ) -> None:
586 from musehub.services.musehub_symbol_indexer import get_index_meta
587
588 repo = await create_repo(db_session, slug="e2e-rebuild")
589 c1 = await _commit_with_delta(db_session, repo.repo_id, "rb-c001",
590 ops=[_insert_op("a.py::Old")])
591 await _build_and_persist(db_session, repo.repo_id, c1.commit_id)
592 await db_session.commit()
593
594 c2 = await _commit_with_delta(db_session, repo.repo_id, "rb-c002",
595 ops=[_insert_op("b.py::New")],
596 parent_ids=[c1.commit_id])
597 await _build_and_persist(db_session, repo.repo_id, c2.commit_id)
598 await db_session.commit()
599
600 meta = await get_index_meta(db_session, repo.repo_id)
601 assert meta is not None
602 assert meta["ref"] == c2.commit_id
603
604 @pytest.mark.asyncio
605 async def test_multi_commit_chain_all_symbols_indexed(
606 self, db_session: AsyncSession
607 ) -> None:
608 """3-commit chain — every symbol from every commit must appear in the index."""
609 from musehub.services.musehub_symbol_indexer import load_symbol_history
610
611 repo = await create_repo(db_session, slug="e2e-chain")
612 c1 = await _commit_with_delta(db_session, repo.repo_id, "chain-c001",
613 ops=[_insert_op("a.py::A1")])
614 c2 = await _commit_with_delta(db_session, repo.repo_id, "chain-c002",
615 ops=[_insert_op("b.py::B1")],
616 parent_ids=[c1.commit_id])
617 c3 = await _commit_with_delta(db_session, repo.repo_id, "chain-c003",
618 ops=[_insert_op("c.py::C1")],
619 parent_ids=[c2.commit_id])
620 await _build_and_persist(db_session, repo.repo_id, c3.commit_id)
621 await db_session.commit()
622
623 history = await load_symbol_history(db_session, repo.repo_id)
624 assert "a.py::A1" in history
625 assert "b.py::B1" in history
626 assert "c.py::C1" in history
627
628
629 # ─────────────────────────────────────────────────────────────────────────────
630 # Layer 4 — Data Integrity
631 # ─────────────────────────────────────────────────────────────────────────────
632
633 class TestDataIntegrity:
634 @pytest.mark.asyncio
635 async def test_upsert_atomic_replace_removes_stale_entries(
636 self, db_session: AsyncSession
637 ) -> None:
638 """Different snap_ids store different manifests independently."""
639 from musehub.services.musehub_snapshot import (
640 get_snapshot_manifest,
641 upsert_snapshot_entries,
642 )
643 repo = await create_repo(db_session, slug="di-atomic")
644 snap_id_a = "snap-atomic-a"
645 snap_id_b = "snap-atomic-b"
646 await upsert_snapshot_entries(
647 db_session, repo.repo_id, snap_id_a,
648 {"old_file.py": "sha256:old", "shared.py": "sha256:shared"},
649 )
650 await db_session.commit()
651
652 await upsert_snapshot_entries(
653 db_session, repo.repo_id, snap_id_b,
654 {"new_file.py": "sha256:new"},
655 )
656 await db_session.commit()
657
658 manifest_b = await get_snapshot_manifest(db_session, snap_id_b)
659 assert "new_file.py" in manifest_b
660 assert "old_file.py" not in manifest_b
661
662 manifest_a = await get_snapshot_manifest(db_session, snap_id_a)
663 assert "old_file.py" in manifest_a
664
665 @pytest.mark.asyncio
666 async def test_only_one_result_per_intel_type_after_multiple_builds(
667 self, db_session: AsyncSession
668 ) -> None:
669 from sqlalchemy import select, func
670
671 repo = await create_repo(db_session, slug="di-onerow")
672 for i in range(3):
673 c = await _commit_with_delta(
674 db_session, repo.repo_id, f"di-c{i:03d}",
675 ops=[_insert_op(f"f{i}.py::Sym")],
676 )
677 await _build_and_persist(db_session, repo.repo_id, c.commit_id)
678 await db_session.commit()
679
680 # code.symbol_history is no longer a blob.
681 # intel_summary must exist with exactly one row (upserted each push).
682 count = (await db_session.execute(
683 select(func.count()).select_from(db.MusehubIntelResult).where(
684 db.MusehubIntelResult.repo_id == repo.repo_id,
685 db.MusehubIntelResult.intel_type == "code.intel_summary",
686 )
687 )).scalar_one()
688 assert count == 1
689
690 @pytest.mark.asyncio
691 async def test_symbol_history_includes_commit_id_and_timestamp(
692 self, db_session: AsyncSession
693 ) -> None:
694 repo = await create_repo(db_session, slug="di-fields")
695 commit = await _commit_with_delta(
696 db_session, repo.repo_id, "di-field-001",
697 ops=[_insert_op("service.py::MyFn", "sha256:myfn")],
698 )
699 from musehub.services.musehub_symbol_indexer import load_symbol_history
700 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
701 await db_session.commit()
702
703 entries = await load_symbol_history(db_session, repo.repo_id)
704 entry = entries["service.py::MyFn"][0]
705 assert entry["commit_id"] == commit.commit_id
706 assert entry["committed_at"] != ""
707 assert entry["op"] == "add"
708 assert entry["content_id"] == "sha256:myfn"
709
710
711 # ─────────────────────────────────────────────────────────────────────────────
712 # Layer 5 — Security
713 # ─────────────────────────────────────────────────────────────────────────────
714
715 class TestSecurity:
716 @pytest.mark.asyncio
717 async def test_corrupt_json_returns_empty_not_exception(
718 self, db_session: AsyncSession
719 ) -> None:
720 """A corrupt code.symbol_history data_json must return {} — not raise."""
721 from musehub.services.musehub_symbol_indexer import load_symbol_history
722 from musehub.core.genesis import compute_intel_result_id
723
724 repo = await create_repo(db_session, slug="sec-corrupt")
725 # Manually insert a row with garbage JSON
726 result_id = compute_intel_result_id(repo.repo_id, "code.symbol_history", "bad-ref")
727 from sqlalchemy.dialects.postgresql import insert as pg_insert
728 await db_session.execute(
729 pg_insert(db.MusehubIntelResult).values(
730 result_id=result_id,
731 repo_id=repo.repo_id,
732 intel_type="code.symbol_history",
733 domain="code",
734 ref="bad-ref",
735 data_json="not valid json {{{{",
736 schema_version=1,
737 computed_at=_now(),
738 ).on_conflict_do_nothing()
739 )
740 await db_session.commit()
741
742 result = await load_symbol_history(db_session, repo.repo_id)
743 assert result == {}
744
745 @pytest.mark.asyncio
746 async def test_build_with_unknown_head_commit_returns_empty(
747 self, db_session: AsyncSession
748 ) -> None:
749 """Unknown head_commit_id must return [], not raise."""
750 from musehub.services.musehub_symbol_indexer import build_symbol_index
751
752 repo = await create_repo(db_session, slug="sec-unknown-head")
753 results = await build_symbol_index(
754 db_session, repo.repo_id, "nonexistent-commit-id"
755 )
756 assert results == []
757
758 @pytest.mark.asyncio
759 async def test_corrupt_hash_occurrence_returns_empty(
760 self, db_session: AsyncSession
761 ) -> None:
762 from musehub.services.musehub_symbol_indexer import load_hash_occurrence
763 from musehub.core.genesis import compute_intel_result_id
764 from sqlalchemy.dialects.postgresql import insert as pg_insert
765
766 repo = await create_repo(db_session, slug="sec-corrupt-hash")
767 result_id = compute_intel_result_id(repo.repo_id, "code.hash_occurrence", "bad-ref")
768 await db_session.execute(
769 pg_insert(db.MusehubIntelResult).values(
770 result_id=result_id,
771 repo_id=repo.repo_id,
772 intel_type="code.hash_occurrence",
773 domain="code",
774 ref="bad-ref",
775 data_json="} invalid {",
776 schema_version=1,
777 computed_at=_now(),
778 ).on_conflict_do_nothing()
779 )
780 await db_session.commit()
781
782 result = await load_hash_occurrence(db_session, repo.repo_id)
783 assert result == {}
784
785
786 # ─────────────────────────────────────────────────────────────────────────────
787 # Layer 5B — Per-symbol intel
788 # ─────────────────────────────────────────────────────────────────────────────
789
790 class TestPerSymbolIntel:
791 @pytest.mark.asyncio
792 async def test_early_return_when_already_current(
793 self, db_session: AsyncSession
794 ) -> None:
795 """When the index is current and code.per_symbol_intel exists,
796 build_symbol_index must return [] (early exit, no recompute)."""
797 from musehub.services.musehub_symbol_indexer import build_symbol_index
798
799 repo = await create_repo(db_session, slug="bfil-current")
800 commit = await _commit_with_delta(
801 db_session, repo.repo_id, "bfil-c001",
802 ops=[_insert_op("svc.py::Handler", "sha256:h1")],
803 )
804 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
805 await db_session.commit()
806
807 # Second call with same head: must early-return (empty list).
808 results2 = await build_symbol_index(db_session, repo.repo_id, commit.commit_id)
809 assert results2 == [], (
810 "build_symbol_index must return [] when index is current "
811 "and per_symbol_intel result exists."
812 )
813
814 @pytest.mark.asyncio
815 async def test_per_symbol_intel_populated_on_first_build(
816 self, db_session: AsyncSession
817 ) -> None:
818 from musehub.services.musehub_symbol_indexer import lookup_symbol_intel
819 repo = await create_repo(db_session, slug="bfil-fresh")
820 commit = await _commit_with_delta(
821 db_session, repo.repo_id, "bfil-fresh-c001",
822 ops=[_insert_op("api.py::Router", "sha256:r1")],
823 )
824 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
825 await db_session.commit()
826
827 psi_data = await lookup_symbol_intel(db_session, repo.repo_id, ["api.py::Router"])
828 assert "api.py::Router" in psi_data
829
830 @pytest.mark.asyncio
831 async def test_per_symbol_intel_contains_expected_fields(
832 self, db_session: AsyncSession
833 ) -> None:
834 from musehub.services.musehub_symbol_indexer import lookup_symbol_intel
835 repo = await create_repo(db_session, slug="bfil-fields")
836 commit = await _commit_with_delta(
837 db_session, repo.repo_id, "bfil-fields-c001",
838 ops=[_insert_op("lib.py::Parser", "sha256:p1")],
839 )
840 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
841 await db_session.commit()
842
843 psi_data = await lookup_symbol_intel(db_session, repo.repo_id, ["lib.py::Parser"])
844 entry = psi_data["lib.py::Parser"]
845 for field in ("churn", "churn_30d", "churn_90d", "blast", "blast_direct",
846 "blast_cross", "blast_top", "last_changed", "last_author",
847 "author_count", "gravity", "weekly"):
848 assert field in entry, f"Missing field '{field}' in per_symbol intel entry."
849
850 @pytest.mark.asyncio
851 async def test_author_count_reflects_unique_authors(
852 self, db_session: AsyncSession
853 ) -> None:
854 repo = await create_repo(db_session, slug="bfil-authors")
855 authors_seq = [("alice", "bfil-authors-c001"), ("bob", "bfil-authors-c002"), ("alice", "bfil-authors-c003")]
856 prev_id: list[str] = []
857 for i, (author, cid) in enumerate(authors_seq, start=1):
858 commit = await _commit_with_delta(
859 db_session, repo.repo_id, cid,
860 ops=[_insert_op("lib.py::Widget", f"sha256:w{i}")],
861 parent_ids=prev_id,
862 author=author,
863 )
864 prev_id = [cid]
865 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
866 await db_session.commit()
867
868 from musehub.services.musehub_symbol_indexer import lookup_symbol_intel
869 psi_data = await lookup_symbol_intel(db_session, repo.repo_id, ["lib.py::Widget"])
870 entry = psi_data["lib.py::Widget"]
871 assert entry["author_count"] == 2, (
872 f"Expected 2 unique authors (alice, bob), got {entry['author_count']}"
873 )
874 assert entry["churn"] == 3
875
876 @pytest.mark.asyncio
877 async def test_lookup_symbol_intel_returns_matching_addresses(
878 self, db_session: AsyncSession
879 ) -> None:
880 from musehub.services.musehub_symbol_indexer import lookup_symbol_intel
881
882 repo = await create_repo(db_session, slug="bfil-lookup")
883 commit = await _commit_with_delta(
884 db_session, repo.repo_id, "bfil-lookup-c001",
885 ops=[
886 _insert_op("a.py::Foo", "sha256:f1"),
887 _insert_op("b.py::Bar", "sha256:b1"),
888 _insert_op("c.py::Baz", "sha256:z1"),
889 ],
890 )
891 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
892 await db_session.commit()
893
894 result = await lookup_symbol_intel(db_session, repo.repo_id, ["a.py::Foo", "c.py::Baz"])
895 assert set(result.keys()) == {"a.py::Foo", "c.py::Baz"}
896 assert "b.py::Bar" not in result
897
898 @pytest.mark.asyncio
899 async def test_lookup_symbol_intel_returns_empty_when_no_index(
900 self, db_session: AsyncSession
901 ) -> None:
902 from musehub.services.musehub_symbol_indexer import lookup_symbol_intel
903
904 repo = await create_repo(db_session, slug="bfil-lookup-null")
905 result = await lookup_symbol_intel(db_session, repo.repo_id, ["core.py::Engine"])
906 assert result == {}
907
908
909 # ─────────────────────────────────────────────────────────────────────────────
910 # Layer 6 — Stress
911 # ─────────────────────────────────────────────────────────────────────────────
912
913 class TestStress:
914 @pytest.mark.asyncio
915 async def test_upsert_1000_file_manifest(self, db_session: AsyncSession) -> None:
916 from musehub.services.musehub_snapshot import (
917 get_snapshot_manifest,
918 upsert_snapshot_entries,
919 )
920 repo = await create_repo(db_session, slug="stress-1k-snap")
921 snap_id = "snap-1k"
922 manifest = {f"src/file_{i:04d}.py": f"sha256:{i:04d}" for i in range(1000)}
923
924 await upsert_snapshot_entries(db_session, repo.repo_id, snap_id, manifest)
925 await db_session.commit()
926
927 result = await get_snapshot_manifest(db_session, snap_id)
928 assert len(result) == 1000
929 assert result["src/file_0500.py"] == "sha256:0500"
930
931 @pytest.mark.asyncio
932 async def test_batch_manifest_50_snapshots(self, db_session: AsyncSession) -> None:
933 from musehub.services.musehub_snapshot import (
934 get_snapshot_manifests_batch,
935 upsert_snapshot_entries,
936 )
937 repo = await create_repo(db_session, slug="stress-batch-50")
938 ids: list[str] = []
939 for i in range(50):
940 sid = f"stress-snap-{i:02d}"
941 ids.append(sid)
942 await upsert_snapshot_entries(
943 db_session, repo.repo_id, sid,
944 {f"f{i}.py": f"sha256:{i}"},
945 )
946 await db_session.commit()
947
948 result = await get_snapshot_manifests_batch(db_session, ids)
949 assert len(result) == 50
950 for i, sid in enumerate(ids):
951 assert f"f{i}.py" in result[sid]
952
953 @pytest.mark.asyncio
954 async def test_build_symbol_index_100_commits(
955 self, db_session: AsyncSession
956 ) -> None:
957 """100-commit chain with 5 ops each — indexer must complete successfully."""
958 from musehub.services.musehub_symbol_indexer import load_symbol_history
959
960 repo = await create_repo(db_session, slug="stress-100-commits")
961 prev_id: str | None = None
962 head_id = "stress-head"
963 for i in range(100):
964 cid = f"stress-{i:04d}" if i < 99 else head_id
965 ops = [_insert_op(f"file{i}.py::Sym{j}", f"sha256:{i}{j}") for j in range(5)]
966 commit = await _commit_with_delta(
967 db_session, repo.repo_id, cid, ops=ops,
968 parent_ids=[prev_id] if prev_id else [],
969 )
970 prev_id = commit.commit_id
971
972 await _build_and_persist(db_session, repo.repo_id, head_id)
973 await db_session.commit()
974
975 history = await load_symbol_history(db_session, repo.repo_id)
976 # 100 files × 5 symbols each = 500 top-level symbol entries
977 assert len(history) == 500
978
979 @pytest.mark.asyncio
980 async def test_load_symbol_history_file_filter_on_large_index(
981 self, db_session: AsyncSession
982 ) -> None:
983 """Filter on large index returns only matching addresses."""
984 from musehub.services.musehub_symbol_indexer import load_symbol_history
985
986 repo = await create_repo(db_session, slug="stress-filter-large")
987 ops = []
988 for i in range(50):
989 for j in range(10):
990 ops.append(_insert_op(f"src/module_{i:02d}.py::Sym{j}", f"sha256:{i}{j}"))
991
992 commit = await _commit_with_delta(db_session, repo.repo_id, "stress-fl-head", ops=ops)
993 await _build_and_persist(db_session, repo.repo_id, commit.commit_id)
994 await db_session.commit()
995
996 result = await load_symbol_history(db_session, repo.repo_id, file_path="src/module_05.py")
997 assert len(result) == 10
998 for key in result:
999 assert key.startswith("src/module_05.py")
File History 1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor 143 days ago