gabriel / musehub public
test_cross_repo_section24.py python
802 lines 27.6 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Section 24 — Workspace & Cross-Repo Intelligence: 7-layer test suite.
2
3 Covers musehub/services/musehub_cross_repo.py and the
4 /{owner}/search UI endpoint in musehub/api/routes/musehub/ui_symbols.py.
5
6 Layer map
7 ---------
8 1. Unit — pure functions, dataclasses
9 2. Integration — service functions against real PostgreSQL DB + symbol index
10 3. E2E — HTTP client against the full app
11 4. Stress — many repos, many symbols, concurrent requests
12 5. Data Integrity — sort order, exclusion rules, limit enforcement
13 6. Security — private repo visibility gating
14 7. Performance — timing budgets
15 """
16 from __future__ import annotations
17
18 import asyncio
19 import time
20 import uuid
21 from datetime import datetime, timezone
22
23 import msgpack
24 import pytest
25 from httpx import AsyncClient
26 from sqlalchemy.ext.asyncio import AsyncSession
27
28 from musehub.muse_contracts.json_types import JSONObject, StrDict
29 from musehub.db.musehub_models import (
30 MusehubRepo,
31 MusehubSymbolIndex,
32 )
33
34 type SymbolHistoryMap = dict[str, list[dict[str, str]]]
35 from musehub.services.musehub_cross_repo import (
36 CrossRepoImpact,
37 CrossRepoMatch,
38 DepsEdge,
39 DepsGraph,
40 DepsNode,
41 ExternalImpact,
42 WorkspaceForecast,
43 WorkspaceRiskEntry,
44 _load_owner_repos,
45 _module_prefix,
46 _short_label,
47 build_deps_graph,
48 cross_repo_impact,
49 search_symbol_across_repos,
50 workspace_blast_risk_top_n,
51 )
52
53
54 # ---------------------------------------------------------------------------
55 # DB helpers
56 # ---------------------------------------------------------------------------
57
58
59 def _uid() -> str:
60 return str(uuid.uuid4())
61
62
63 async def _db_repo(
64 session: AsyncSession,
65 owner: str = "alice",
66 *,
67 name: str | None = None,
68 visibility: str = "public",
69 deleted: bool = False,
70 ) -> MusehubRepo:
71 slug = name or f"repo-{_uid()[:8]}"
72 from datetime import timezone
73
74 repo = MusehubRepo(
75 repo_id=_uid(),
76 name=slug,
77 slug=slug,
78 owner=owner,
79 owner_user_id=owner,
80 visibility=visibility,
81 deleted_at=datetime.now(timezone.utc) if deleted else None,
82 )
83 session.add(repo)
84 await session.flush()
85 return repo
86
87
88 def _pack_history(entries: SymbolHistoryMap) -> bytes:
89 """Serialize a symbol_history dict as msgpack blob."""
90 return msgpack.packb({"entries": entries}, use_bin_type=True)
91
92
93 def _entry(commit_id: str, *, op: str = "add", committed_at: str = "2026-01-01T00:00:00") -> JSONObject:
94 return {"commit_id": commit_id, "op": op, "committed_at": committed_at}
95
96
97 async def _db_symbol_index(
98 session: AsyncSession,
99 repo_id: str,
100 symbol_history: SymbolHistoryMap,
101 ) -> None:
102 """Insert a MusehubSymbolIndex row with packed symbol_history."""
103 row = MusehubSymbolIndex(
104 repo_id=repo_id,
105 ref=_uid(),
106 symbol_history=_pack_history(symbol_history),
107 )
108 session.add(row)
109 await session.flush()
110
111
112 # ===========================================================================
113 # Layer 1 — Unit
114 # ===========================================================================
115
116
117 class TestUnitModulePrefix:
118 def test_returns_first_three_segments(self) -> None:
119 assert _module_prefix("musehub.services.musehub_ci.enqueue_run") == "musehub.services.musehub_ci"
120
121 def test_exactly_three_segments(self) -> None:
122 assert _module_prefix("a.b.c") == "a.b.c"
123
124 def test_fewer_than_depth_returns_address(self) -> None:
125 assert _module_prefix("a.b") == "a.b"
126
127 def test_single_segment_unchanged(self) -> None:
128 assert _module_prefix("module") == "module"
129
130 def test_custom_depth_two(self) -> None:
131 assert _module_prefix("a.b.c.d", depth=2) == "a.b"
132
133 def test_address_with_double_colon(self) -> None:
134 # Dot-separated only; :: is ignored by _module_prefix
135 result = _module_prefix("musehub.services.musehub_ci::fn_name")
136 # Only splits on dots; the colons stay as-is
137 assert result.startswith("musehub.services")
138
139
140 class TestUnitShortLabel:
141 def test_returns_last_two_segments(self) -> None:
142 assert _short_label("musehub.services.musehub_ci") == "services.musehub_ci"
143
144 def test_two_segments_unchanged(self) -> None:
145 assert _short_label("services.musehub_ci") == "services.musehub_ci"
146
147 def test_single_segment_unchanged(self) -> None:
148 assert _short_label("module") == "module"
149
150 def test_long_address(self) -> None:
151 assert _short_label("a.b.c.d.e") == "d.e"
152
153
154 class TestUnitDataclasses:
155 def test_cross_repo_match_fields(self) -> None:
156 m = CrossRepoMatch(
157 repo_id="r1",
158 repo_slug="my-repo",
159 address="file.py::Foo",
160 last_op="modify",
161 co_change_count=3,
162 )
163 assert m.co_change_count == 3
164
165 def test_external_impact_fields(self) -> None:
166 ei = ExternalImpact(
167 repo_id="r2", repo_slug="other", matches=[{"address": "a", "shared_commits": 2}]
168 )
169 assert len(ei.matches) == 1
170
171 def test_cross_repo_impact_fields(self) -> None:
172 cri = CrossRepoImpact(
173 address="file.py::Foo",
174 source_repo_id="r1",
175 source_repo_slug="my-repo",
176 local_co_changed=[],
177 local_commit_count=5,
178 external=[],
179 )
180 assert cri.local_commit_count == 5
181
182 def test_workspace_risk_entry_fields(self) -> None:
183 wre = WorkspaceRiskEntry(
184 address="file.py::Bar",
185 repo_id="r1",
186 repo_slug="my-repo",
187 co_change_count=10,
188 commit_count=7,
189 )
190 assert wre.commit_count == 7
191
192 def test_deps_node_fields(self) -> None:
193 node = DepsNode(
194 id="musehub.services.ci",
195 label="services.ci",
196 type="local",
197 repo_id="r1",
198 repo_slug="my-repo",
199 address_count=5,
200 )
201 assert node.type == "local"
202
203 def test_deps_edge_fields(self) -> None:
204 edge = DepsEdge(source="a", target="b", weight=3, type="co_change")
205 assert edge.weight == 3
206
207 def test_deps_graph_default_empty(self) -> None:
208 g = DepsGraph()
209 assert g.nodes == []
210 assert g.edges == []
211
212 def test_workspace_forecast_fields(self) -> None:
213 wf = WorkspaceForecast(owner="alice", repos=[], cross_repo_risk_symbols=[])
214 assert wf.owner == "alice"
215
216
217 # ===========================================================================
218 # Layer 2 — Integration
219 # ===========================================================================
220
221
222 class TestIntegrationLoadOwnerRepos:
223 @pytest.mark.anyio
224 async def test_returns_public_repos_for_unauthenticated(
225 self, db_session: AsyncSession
226 ) -> None:
227 pub = await _db_repo(db_session, "alice", visibility="public")
228 priv = await _db_repo(db_session, "alice", visibility="private")
229 await db_session.flush()
230
231 repos = await _load_owner_repos(db_session, "alice", visible_to_user=None)
232 ids = [r.repo_id for r in repos]
233 assert pub.repo_id in ids
234 assert priv.repo_id not in ids
235
236 @pytest.mark.anyio
237 async def test_owner_sees_all_repos(self, db_session: AsyncSession) -> None:
238 pub = await _db_repo(db_session, "alice", visibility="public")
239 priv = await _db_repo(db_session, "alice", visibility="private")
240 await db_session.flush()
241
242 repos = await _load_owner_repos(db_session, "alice", visible_to_user="alice")
243 ids = [r.repo_id for r in repos]
244 assert pub.repo_id in ids
245 assert priv.repo_id in ids
246
247 @pytest.mark.anyio
248 async def test_deleted_repos_excluded(self, db_session: AsyncSession) -> None:
249 active = await _db_repo(db_session, "alice", visibility="public")
250 deleted = await _db_repo(db_session, "alice", visibility="public", deleted=True)
251 await db_session.flush()
252
253 repos = await _load_owner_repos(db_session, "alice", visible_to_user="alice")
254 ids = [r.repo_id for r in repos]
255 assert active.repo_id in ids
256 assert deleted.repo_id not in ids
257
258 @pytest.mark.anyio
259 async def test_other_owner_repos_excluded(self, db_session: AsyncSession) -> None:
260 alice_repo = await _db_repo(db_session, "alice", visibility="public")
261 bob_repo = await _db_repo(db_session, "bob", visibility="public")
262 await db_session.flush()
263
264 repos = await _load_owner_repos(db_session, "alice", visible_to_user="alice")
265 ids = [r.repo_id for r in repos]
266 assert alice_repo.repo_id in ids
267 assert bob_repo.repo_id not in ids
268
269
270 class TestIntegrationSearchSymbolAcrossRepos:
271 @pytest.mark.anyio
272 async def test_finds_matching_symbol(self, db_session: AsyncSession) -> None:
273 repo = await _db_repo(db_session, "alice", visibility="public")
274 c_id = _uid()
275 await _db_symbol_index(
276 db_session,
277 repo.repo_id,
278 {"musehub.services.ci::enqueue_run": [_entry(c_id)]},
279 )
280 await db_session.flush()
281
282 results = await search_symbol_across_repos(
283 db_session, "alice", "enqueue_run", visible_to_user="alice"
284 )
285 assert any("enqueue_run" in r.address for r in results)
286
287 @pytest.mark.anyio
288 async def test_case_insensitive_match(self, db_session: AsyncSession) -> None:
289 repo = await _db_repo(db_session, "alice", visibility="public")
290 c_id = _uid()
291 await _db_symbol_index(
292 db_session,
293 repo.repo_id,
294 {"musehub.services.ci::EnqueueRun": [_entry(c_id)]},
295 )
296 await db_session.flush()
297
298 results = await search_symbol_across_repos(
299 db_session, "alice", "enqueuerun", visible_to_user="alice"
300 )
301 assert any("EnqueueRun" in r.address for r in results)
302
303 @pytest.mark.anyio
304 async def test_no_match_returns_empty(self, db_session: AsyncSession) -> None:
305 repo = await _db_repo(db_session, "alice", visibility="public")
306 c_id = _uid()
307 await _db_symbol_index(
308 db_session, repo.repo_id, {"file.py::Foo": [_entry(c_id)]}
309 )
310 await db_session.flush()
311
312 results = await search_symbol_across_repos(
313 db_session, "alice", "no_such_symbol_xyz", visible_to_user="alice"
314 )
315 assert results == []
316
317 @pytest.mark.anyio
318 async def test_limit_respected(self, db_session: AsyncSession) -> None:
319 repo = await _db_repo(db_session, "alice", visibility="public")
320 history = {f"file.py::Sym{i}": [_entry(_uid())] for i in range(20)}
321 await _db_symbol_index(db_session, repo.repo_id, history)
322 await db_session.flush()
323
324 results = await search_symbol_across_repos(
325 db_session, "alice", "Sym", limit=5, visible_to_user="alice"
326 )
327 assert len(results) <= 5
328
329 @pytest.mark.anyio
330 async def test_private_repo_invisible_to_others(
331 self, db_session: AsyncSession
332 ) -> None:
333 repo = await _db_repo(db_session, "alice", visibility="private")
334 c_id = _uid()
335 await _db_symbol_index(
336 db_session, repo.repo_id, {"file.py::SecretFn": [_entry(c_id)]}
337 )
338 await db_session.flush()
339
340 results = await search_symbol_across_repos(
341 db_session, "alice", "SecretFn", visible_to_user="bob"
342 )
343 assert results == []
344
345 @pytest.mark.anyio
346 async def test_repo_without_index_skipped(self, db_session: AsyncSession) -> None:
347 await _db_repo(db_session, "alice", visibility="public")
348 await db_session.flush()
349
350 results = await search_symbol_across_repos(
351 db_session, "alice", "anything", visible_to_user="alice"
352 )
353 assert results == []
354
355
356 class TestIntegrationCrossRepoImpact:
357 @pytest.mark.anyio
358 async def test_returns_none_if_source_repo_not_in_workspace(
359 self, db_session: AsyncSession
360 ) -> None:
361 await db_session.flush()
362 result = await cross_repo_impact(
363 db_session, "alice", "nonexistent-repo", "file.py::Foo",
364 visible_to_user="alice"
365 )
366 assert result is None
367
368 @pytest.mark.anyio
369 async def test_returns_none_if_address_not_in_index(
370 self, db_session: AsyncSession
371 ) -> None:
372 repo = await _db_repo(db_session, "alice", visibility="public")
373 c_id = _uid()
374 await _db_symbol_index(db_session, repo.repo_id, {"file.py::OtherFn": [_entry(c_id)]})
375 await db_session.flush()
376
377 result = await cross_repo_impact(
378 db_session, "alice", repo.repo_id, "file.py::Missing",
379 visible_to_user="alice"
380 )
381 assert result is None
382
383 @pytest.mark.anyio
384 async def test_returns_impact_for_valid_address(
385 self, db_session: AsyncSession
386 ) -> None:
387 repo = await _db_repo(db_session, "alice", visibility="public")
388 c_id = _uid()
389 await _db_symbol_index(
390 db_session,
391 repo.repo_id,
392 {
393 "file.py::Foo": [_entry(c_id)],
394 "file.py::Bar": [_entry(c_id)], # co-changes with Foo
395 },
396 )
397 await db_session.flush()
398
399 result = await cross_repo_impact(
400 db_session, "alice", repo.repo_id, "file.py::Foo",
401 visible_to_user="alice"
402 )
403 assert result is not None
404 assert result.address == "file.py::Foo"
405 assert result.source_repo_id == repo.repo_id
406 # Bar co-changes with Foo in the same commit
407 local_addresses = [e["address"] for e in result.local_co_changed]
408 assert "file.py::Bar" in local_addresses
409
410
411 class TestIntegrationWorkspaceBlastRisk:
412 @pytest.mark.anyio
413 async def test_returns_top_n_symbols(self, db_session: AsyncSession) -> None:
414 repo = await _db_repo(db_session, "alice", visibility="public")
415 # sym_a: 5 commit entries; sym_b: 2
416 entries_a = [_entry(f"c{i}") for i in range(5)]
417 entries_b = [_entry(f"d{i}") for i in range(2)]
418 await _db_symbol_index(
419 db_session,
420 repo.repo_id,
421 {"file.py::sym_a": entries_a, "file.py::sym_b": entries_b},
422 )
423 await db_session.flush()
424
425 results = await workspace_blast_risk_top_n(
426 db_session, "alice", top_n=1, visible_to_user="alice"
427 )
428 assert len(results) == 1
429 assert results[0].address == "file.py::sym_a"
430
431 @pytest.mark.anyio
432 async def test_sorted_by_co_change_count_desc(
433 self, db_session: AsyncSession
434 ) -> None:
435 repo = await _db_repo(db_session, "alice", visibility="public")
436 entries = {f"file.py::sym_{i}": [_entry(_uid())] * (10 - i) for i in range(5)}
437 await _db_symbol_index(db_session, repo.repo_id, entries)
438 await db_session.flush()
439
440 results = await workspace_blast_risk_top_n(
441 db_session, "alice", top_n=5, visible_to_user="alice"
442 )
443 counts = [r.co_change_count for r in results]
444 assert counts == sorted(counts, reverse=True)
445
446
447 class TestIntegrationBuildDepsGraph:
448 @pytest.mark.anyio
449 async def test_source_repo_not_in_workspace_returns_empty(
450 self, db_session: AsyncSession
451 ) -> None:
452 await db_session.flush()
453 g = await build_deps_graph(
454 db_session, "alice", "nonexistent", visible_to_user="alice"
455 )
456 assert g.nodes == []
457 assert g.edges == []
458
459 @pytest.mark.anyio
460 async def test_builds_nodes_from_symbol_history(
461 self, db_session: AsyncSession
462 ) -> None:
463 repo = await _db_repo(db_session, "alice", visibility="public")
464 c_id = _uid()
465 # Use dot-only addresses so _module_prefix produces clean 3-segment node IDs
466 await _db_symbol_index(
467 db_session,
468 repo.repo_id,
469 {
470 "musehub.services.ci.run": [_entry(c_id)],
471 "musehub.services.ci.cancel": [_entry(c_id)],
472 "musehub.services.auth.login": [_entry(c_id)],
473 },
474 )
475 await db_session.flush()
476
477 g = await build_deps_graph(
478 db_session, "alice", repo.repo_id, visible_to_user="alice"
479 )
480 node_ids = [n.id for n in g.nodes]
481 # _module_prefix("musehub.services.ci.run") → "musehub.services.ci"
482 assert "musehub.services.ci" in node_ids
483
484 @pytest.mark.anyio
485 async def test_no_symbol_history_returns_empty_graph(
486 self, db_session: AsyncSession
487 ) -> None:
488 repo = await _db_repo(db_session, "alice", visibility="public")
489 await db_session.flush()
490
491 g = await build_deps_graph(
492 db_session, "alice", repo.repo_id, visible_to_user="alice"
493 )
494 assert g.nodes == []
495
496
497 # ===========================================================================
498 # Layer 3 — E2E
499 # ===========================================================================
500
501
502 class TestE2ESymbolSearch:
503 @pytest.mark.anyio
504 async def test_search_page_200_with_query(
505 self,
506 client: AsyncClient,
507 auth_headers: StrDict,
508 db_session: AsyncSession,
509 ) -> None:
510 repo = await _db_repo(db_session, "testuser", visibility="public")
511 await _db_symbol_index(
512 db_session, repo.repo_id, {"file.py::MyFunc": [_entry(_uid())]}
513 )
514 await db_session.commit()
515
516 r = await client.get("/testuser/search?q=MyFunc", headers=auth_headers)
517 assert r.status_code == 200
518
519 @pytest.mark.anyio
520 async def test_search_page_200_empty_query(
521 self,
522 client: AsyncClient,
523 auth_headers: StrDict,
524 ) -> None:
525 r = await client.get("/testuser/search", headers=auth_headers)
526 assert r.status_code == 200
527
528 @pytest.mark.anyio
529 async def test_search_page_no_auth_public_owner(
530 self,
531 client: AsyncClient,
532 db_session: AsyncSession,
533 ) -> None:
534 """Public symbol search is accessible without auth token."""
535 repo = await _db_repo(db_session, "testuser", visibility="public")
536 await _db_symbol_index(
537 db_session, repo.repo_id, {"file.py::PubFn": [_entry(_uid())]}
538 )
539 await db_session.commit()
540
541 r = await client.get("/testuser/search?q=PubFn")
542 # UI route renders HTML; should succeed (200)
543 assert r.status_code == 200
544
545 @pytest.mark.anyio
546 async def test_search_returns_html(
547 self,
548 client: AsyncClient,
549 auth_headers: StrDict,
550 ) -> None:
551 r = await client.get("/testuser/search?q=foo", headers=auth_headers)
552 assert r.status_code == 200
553 assert "text/html" in r.headers.get("content-type", "")
554
555
556 # ===========================================================================
557 # Layer 4 — Stress
558 # ===========================================================================
559
560
561 class TestStress:
562 @pytest.mark.anyio
563 async def test_search_across_10_repos(self, db_session: AsyncSession) -> None:
564 for i in range(10):
565 repo = await _db_repo(db_session, "alice", name=f"repo-{i}", visibility="public")
566 history = {f"file.py::Sym{i}_{j}": [_entry(_uid())] for j in range(10)}
567 await _db_symbol_index(db_session, repo.repo_id, history)
568 await db_session.flush()
569
570 results = await search_symbol_across_repos(
571 db_session, "alice", "Sym", limit=30, visible_to_user="alice"
572 )
573 assert len(results) <= 30
574
575 @pytest.mark.anyio
576 async def test_concurrent_workspace_blast_risk(
577 self, db_session: AsyncSession
578 ) -> None:
579 repo = await _db_repo(db_session, "alice", visibility="public")
580 history = {f"file.py::sym_{i}": [_entry(_uid())] * 3 for i in range(30)}
581 await _db_symbol_index(db_session, repo.repo_id, history)
582 await db_session.flush()
583
584 results = await asyncio.gather(
585 *[
586 workspace_blast_risk_top_n(db_session, "alice", top_n=10, visible_to_user="alice")
587 for _ in range(5)
588 ]
589 )
590 assert all(len(r) <= 10 for r in results)
591
592 @pytest.mark.anyio
593 async def test_blast_risk_100_symbols(self, db_session: AsyncSession) -> None:
594 repo = await _db_repo(db_session, "alice", visibility="public")
595 history = {
596 f"musehub.services.mod_{i}::fn_{j}": [_entry(_uid())] * (i + 1)
597 for i in range(10)
598 for j in range(10)
599 }
600 await _db_symbol_index(db_session, repo.repo_id, history)
601 await db_session.flush()
602
603 results = await workspace_blast_risk_top_n(
604 db_session, "alice", top_n=20, visible_to_user="alice"
605 )
606 assert len(results) == 20
607
608
609 # ===========================================================================
610 # Layer 5 — Data Integrity
611 # ===========================================================================
612
613
614 class TestDataIntegrity:
615 @pytest.mark.anyio
616 async def test_search_results_sorted_by_co_change_desc(
617 self, db_session: AsyncSession
618 ) -> None:
619 repo = await _db_repo(db_session, "alice", visibility="public")
620 history = {
621 "file.py::Rarely": [_entry(_uid())],
622 "file.py::Often": [_entry(_uid())] * 8,
623 "file.py::Medium": [_entry(_uid())] * 3,
624 }
625 await _db_symbol_index(db_session, repo.repo_id, history)
626 await db_session.flush()
627
628 results = await search_symbol_across_repos(
629 db_session, "alice", "file.py", visible_to_user="alice"
630 )
631 counts = [r.co_change_count for r in results]
632 assert counts == sorted(counts, reverse=True)
633
634 @pytest.mark.anyio
635 async def test_blast_risk_top_n_hard_cap(
636 self, db_session: AsyncSession
637 ) -> None:
638 repo = await _db_repo(db_session, "alice", visibility="public")
639 history = {f"file.py::sym_{i}": [_entry(_uid())] for i in range(50)}
640 await _db_symbol_index(db_session, repo.repo_id, history)
641 await db_session.flush()
642
643 results = await workspace_blast_risk_top_n(
644 db_session, "alice", top_n=10, visible_to_user="alice"
645 )
646 assert len(results) == 10
647
648 @pytest.mark.anyio
649 async def test_cross_repo_match_fields_populated(
650 self, db_session: AsyncSession
651 ) -> None:
652 repo = await _db_repo(db_session, "alice", visibility="public")
653 c_id = _uid()
654 await _db_symbol_index(
655 db_session, repo.repo_id, {"a.b.c::MyFn": [_entry(c_id)]}
656 )
657 await db_session.flush()
658
659 results = await search_symbol_across_repos(
660 db_session, "alice", "MyFn", visible_to_user="alice"
661 )
662 assert len(results) == 1
663 m = results[0]
664 assert m.repo_id == repo.repo_id
665 assert m.address == "a.b.c::MyFn"
666 assert m.last_op in ("add", "modify", "delete")
667 assert m.co_change_count == 1
668
669 @pytest.mark.anyio
670 async def test_deps_graph_max_nodes_cap(
671 self, db_session: AsyncSession
672 ) -> None:
673 repo = await _db_repo(db_session, "alice", visibility="public")
674 history = {
675 f"module_{i}.sub.fn::Sym": [_entry(_uid())]
676 for i in range(80)
677 }
678 await _db_symbol_index(db_session, repo.repo_id, history)
679 await db_session.flush()
680
681 g = await build_deps_graph(
682 db_session, "alice", repo.repo_id,
683 visible_to_user="alice", max_nodes=60
684 )
685 assert len(g.nodes) <= 60
686
687
688 # ===========================================================================
689 # Layer 6 — Security
690 # ===========================================================================
691
692
693 class TestSecurity:
694 @pytest.mark.anyio
695 async def test_private_repo_symbols_invisible_to_non_owner(
696 self, db_session: AsyncSession
697 ) -> None:
698 repo = await _db_repo(db_session, "alice", visibility="private")
699 await _db_symbol_index(
700 db_session, repo.repo_id, {"secret.py::SecretKey": [_entry(_uid())]}
701 )
702 await db_session.flush()
703
704 results = await search_symbol_across_repos(
705 db_session, "alice", "SecretKey", visible_to_user="bob"
706 )
707 assert results == []
708
709 @pytest.mark.anyio
710 async def test_unauthenticated_only_sees_public(
711 self, db_session: AsyncSession
712 ) -> None:
713 pub_repo = await _db_repo(db_session, "alice", visibility="public")
714 priv_repo = await _db_repo(db_session, "alice", visibility="private")
715 c1, c2 = _uid(), _uid()
716 await _db_symbol_index(db_session, pub_repo.repo_id, {"pub.py::PubFn": [_entry(c1)]})
717 await _db_symbol_index(db_session, priv_repo.repo_id, {"priv.py::PrivFn": [_entry(c2)]})
718 await db_session.flush()
719
720 results = await search_symbol_across_repos(
721 db_session, "alice", "Fn", visible_to_user=None
722 )
723 addresses = [r.address for r in results]
724 assert "pub.py::PubFn" in addresses
725 assert "priv.py::PrivFn" not in addresses
726
727 @pytest.mark.anyio
728 async def test_blast_risk_private_repo_invisible_to_others(
729 self, db_session: AsyncSession
730 ) -> None:
731 priv = await _db_repo(db_session, "alice", visibility="private")
732 await _db_symbol_index(
733 db_session, priv.repo_id, {"file.py::Hidden": [_entry(_uid())] * 10}
734 )
735 await db_session.flush()
736
737 results = await workspace_blast_risk_top_n(
738 db_session, "alice", top_n=20, visible_to_user="bob"
739 )
740 assert all(r.repo_id != priv.repo_id for r in results)
741
742 @pytest.mark.anyio
743 async def test_cross_repo_impact_private_source_invisible(
744 self, db_session: AsyncSession
745 ) -> None:
746 """cross_repo_impact returns None when source repo is private and caller is not owner."""
747 priv = await _db_repo(db_session, "alice", visibility="private")
748 c_id = _uid()
749 await _db_symbol_index(
750 db_session, priv.repo_id, {"file.py::Fn": [_entry(c_id)]}
751 )
752 await db_session.flush()
753
754 result = await cross_repo_impact(
755 db_session, "alice", priv.repo_id, "file.py::Fn",
756 visible_to_user="bob"
757 )
758 # bob can't see alice's private repo → source_repo is None → returns None
759 assert result is None
760
761
762 # ===========================================================================
763 # Layer 7 — Performance
764 # ===========================================================================
765
766
767 class TestPerformance:
768 @pytest.mark.anyio
769 async def test_search_across_5_repos_under_300ms(
770 self, db_session: AsyncSession
771 ) -> None:
772 for i in range(5):
773 repo = await _db_repo(db_session, "alice", name=f"perf-{i}", visibility="public")
774 history = {f"file.py::Sym{i}_{j}": [_entry(_uid())] for j in range(20)}
775 await _db_symbol_index(db_session, repo.repo_id, history)
776 await db_session.flush()
777
778 start = time.perf_counter()
779 results = await search_symbol_across_repos(
780 db_session, "alice", "Sym", limit=30, visible_to_user="alice"
781 )
782 elapsed = time.perf_counter() - start
783
784 assert elapsed < 0.3, f"search took {elapsed:.3f}s, expected <0.3s"
785 assert len(results) <= 30
786
787 @pytest.mark.anyio
788 async def test_workspace_blast_risk_50_symbols_under_200ms(
789 self, db_session: AsyncSession
790 ) -> None:
791 repo = await _db_repo(db_session, "alice", visibility="public")
792 history = {f"file.py::sym_{i}": [_entry(_uid())] * (i % 5 + 1) for i in range(50)}
793 await _db_symbol_index(db_session, repo.repo_id, history)
794 await db_session.flush()
795
796 start = time.perf_counter()
797 results = await workspace_blast_risk_top_n(
798 db_session, "alice", top_n=20, visible_to_user="alice"
799 )
800 elapsed = time.perf_counter() - start
801
802 assert elapsed < 0.2, f"blast risk took {elapsed:.3f}s, expected <0.2s"
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago