gabriel / musehub public
test_cross_repo.py python
767 lines 26.7 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 155 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
22 import msgpack
23 import pytest
24 from httpx import AsyncClient
25 from sqlalchemy.ext.asyncio import AsyncSession
26
27 from musehub.types.json_types import JSONObject, StrDict, SymbolHistoryEntry
28 from musehub.db.musehub_models import (
29 MusehubRepo,
30 MusehubSymbolIndex,
31 )
32
33 type SymbolHistoryMap = dict[str, list[SymbolHistoryEntry]]
34 from musehub.services.musehub_cross_repo import (
35 CrossRepoImpact,
36 CrossRepoMatch,
37 DepsEdge,
38 DepsGraph,
39 DepsNode,
40 ExternalImpact,
41 WorkspaceForecast,
42 WorkspaceRiskEntry,
43 _load_owner_repos,
44 _module_prefix,
45 _short_label,
46 build_deps_graph,
47 cross_repo_impact,
48 search_symbol_across_repos,
49 workspace_blast_risk_top_n,
50 )
51
52
53 # ---------------------------------------------------------------------------
54 # DB helpers
55 # ---------------------------------------------------------------------------
56
57
58 def _uid() -> str:
59 return str(uuid.uuid4())
60
61
62 async def _db_repo(
63 session: AsyncSession,
64 owner: str = "alice",
65 *,
66 name: str | None = None,
67 visibility: str = "public",
68 deleted: bool = False,
69 ) -> MusehubRepo:
70 slug = name or f"repo-{_uid()[:8]}"
71
72 repo = MusehubRepo(
73 repo_id=_uid(),
74 name=slug,
75 slug=slug,
76 owner=owner,
77 owner_user_id=owner,
78 visibility=visibility,
79 )
80 session.add(repo)
81 await session.flush()
82 if deleted:
83 await session.delete(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 async def test_returns_public_repos_for_unauthenticated(
224 self, db_session: AsyncSession
225 ) -> None:
226 pub = await _db_repo(db_session, "alice", visibility="public")
227 priv = await _db_repo(db_session, "alice", visibility="private")
228 await db_session.flush()
229
230 repos = await _load_owner_repos(db_session, "alice", visible_to_user=None)
231 ids = [r.repo_id for r in repos]
232 assert pub.repo_id in ids
233 assert priv.repo_id not in ids
234
235 async def test_owner_sees_all_repos(self, db_session: AsyncSession) -> None:
236 pub = await _db_repo(db_session, "alice", visibility="public")
237 priv = await _db_repo(db_session, "alice", visibility="private")
238 await db_session.flush()
239
240 repos = await _load_owner_repos(db_session, "alice", visible_to_user="alice")
241 ids = [r.repo_id for r in repos]
242 assert pub.repo_id in ids
243 assert priv.repo_id in ids
244
245 async def test_deleted_repos_excluded(self, db_session: AsyncSession) -> None:
246 active = await _db_repo(db_session, "alice", visibility="public")
247 deleted = await _db_repo(db_session, "alice", visibility="public", deleted=True)
248 await db_session.flush()
249
250 repos = await _load_owner_repos(db_session, "alice", visible_to_user="alice")
251 ids = [r.repo_id for r in repos]
252 assert active.repo_id in ids
253 assert deleted.repo_id not in ids
254
255 async def test_other_owner_repos_excluded(self, db_session: AsyncSession) -> None:
256 alice_repo = await _db_repo(db_session, "alice", visibility="public")
257 bob_repo = await _db_repo(db_session, "bob", visibility="public")
258 await db_session.flush()
259
260 repos = await _load_owner_repos(db_session, "alice", visible_to_user="alice")
261 ids = [r.repo_id for r in repos]
262 assert alice_repo.repo_id in ids
263 assert bob_repo.repo_id not in ids
264
265
266 class TestIntegrationSearchSymbolAcrossRepos:
267 async def test_finds_matching_symbol(self, db_session: AsyncSession) -> None:
268 repo = await _db_repo(db_session, "alice", visibility="public")
269 c_id = _uid()
270 await _db_symbol_index(
271 db_session,
272 repo.repo_id,
273 {"musehub.services.ci::enqueue_run": [_entry(c_id)]},
274 )
275 await db_session.flush()
276
277 results = await search_symbol_across_repos(
278 db_session, "alice", "enqueue_run", visible_to_user="alice"
279 )
280 assert any("enqueue_run" in r.address for r in results)
281
282 async def test_case_insensitive_match(self, db_session: AsyncSession) -> None:
283 repo = await _db_repo(db_session, "alice", visibility="public")
284 c_id = _uid()
285 await _db_symbol_index(
286 db_session,
287 repo.repo_id,
288 {"musehub.services.ci::EnqueueRun": [_entry(c_id)]},
289 )
290 await db_session.flush()
291
292 results = await search_symbol_across_repos(
293 db_session, "alice", "enqueuerun", visible_to_user="alice"
294 )
295 assert any("EnqueueRun" in r.address for r in results)
296
297 async def test_no_match_returns_empty(self, db_session: AsyncSession) -> None:
298 repo = await _db_repo(db_session, "alice", visibility="public")
299 c_id = _uid()
300 await _db_symbol_index(
301 db_session, repo.repo_id, {"file.py::Foo": [_entry(c_id)]}
302 )
303 await db_session.flush()
304
305 results = await search_symbol_across_repos(
306 db_session, "alice", "no_such_symbol_xyz", visible_to_user="alice"
307 )
308 assert results == []
309
310 async def test_limit_respected(self, db_session: AsyncSession) -> None:
311 repo = await _db_repo(db_session, "alice", visibility="public")
312 history = {f"file.py::Sym{i}": [_entry(_uid())] for i in range(20)}
313 await _db_symbol_index(db_session, repo.repo_id, history)
314 await db_session.flush()
315
316 results = await search_symbol_across_repos(
317 db_session, "alice", "Sym", limit=5, visible_to_user="alice"
318 )
319 assert len(results) <= 5
320
321 async def test_private_repo_invisible_to_others(
322 self, db_session: AsyncSession
323 ) -> None:
324 repo = await _db_repo(db_session, "alice", visibility="private")
325 c_id = _uid()
326 await _db_symbol_index(
327 db_session, repo.repo_id, {"file.py::SecretFn": [_entry(c_id)]}
328 )
329 await db_session.flush()
330
331 results = await search_symbol_across_repos(
332 db_session, "alice", "SecretFn", visible_to_user="bob"
333 )
334 assert results == []
335
336 async def test_repo_without_index_skipped(self, db_session: AsyncSession) -> None:
337 await _db_repo(db_session, "alice", visibility="public")
338 await db_session.flush()
339
340 results = await search_symbol_across_repos(
341 db_session, "alice", "anything", visible_to_user="alice"
342 )
343 assert results == []
344
345
346 class TestIntegrationCrossRepoImpact:
347 async def test_returns_none_if_source_repo_not_in_workspace(
348 self, db_session: AsyncSession
349 ) -> None:
350 await db_session.flush()
351 result = await cross_repo_impact(
352 db_session, "alice", "nonexistent-repo", "file.py::Foo",
353 visible_to_user="alice"
354 )
355 assert result is None
356
357 async def test_returns_none_if_address_not_in_index(
358 self, db_session: AsyncSession
359 ) -> None:
360 repo = await _db_repo(db_session, "alice", visibility="public")
361 c_id = _uid()
362 await _db_symbol_index(db_session, repo.repo_id, {"file.py::OtherFn": [_entry(c_id)]})
363 await db_session.flush()
364
365 result = await cross_repo_impact(
366 db_session, "alice", repo.repo_id, "file.py::Missing",
367 visible_to_user="alice"
368 )
369 assert result is None
370
371 async def test_returns_impact_for_valid_address(
372 self, db_session: AsyncSession
373 ) -> None:
374 repo = await _db_repo(db_session, "alice", visibility="public")
375 c_id = _uid()
376 await _db_symbol_index(
377 db_session,
378 repo.repo_id,
379 {
380 "file.py::Foo": [_entry(c_id)],
381 "file.py::Bar": [_entry(c_id)], # co-changes with Foo
382 },
383 )
384 await db_session.flush()
385
386 result = await cross_repo_impact(
387 db_session, "alice", repo.repo_id, "file.py::Foo",
388 visible_to_user="alice"
389 )
390 assert result is not None
391 assert result.address == "file.py::Foo"
392 assert result.source_repo_id == repo.repo_id
393 # Bar co-changes with Foo in the same commit
394 local_addresses = [e["address"] for e in result.local_co_changed]
395 assert "file.py::Bar" in local_addresses
396
397
398 class TestIntegrationWorkspaceBlastRisk:
399 async def test_returns_top_n_symbols(self, db_session: AsyncSession) -> None:
400 repo = await _db_repo(db_session, "alice", visibility="public")
401 # sym_a: 5 commit entries; sym_b: 2
402 entries_a = [_entry(f"c{i}") for i in range(5)]
403 entries_b = [_entry(f"d{i}") for i in range(2)]
404 await _db_symbol_index(
405 db_session,
406 repo.repo_id,
407 {"file.py::sym_a": entries_a, "file.py::sym_b": entries_b},
408 )
409 await db_session.flush()
410
411 results = await workspace_blast_risk_top_n(
412 db_session, "alice", top_n=1, visible_to_user="alice"
413 )
414 assert len(results) == 1
415 assert results[0].address == "file.py::sym_a"
416
417 async def test_sorted_by_co_change_count_desc(
418 self, db_session: AsyncSession
419 ) -> None:
420 repo = await _db_repo(db_session, "alice", visibility="public")
421 entries = {f"file.py::sym_{i}": [_entry(_uid())] * (10 - i) for i in range(5)}
422 await _db_symbol_index(db_session, repo.repo_id, entries)
423 await db_session.flush()
424
425 results = await workspace_blast_risk_top_n(
426 db_session, "alice", top_n=5, visible_to_user="alice"
427 )
428 counts = [r.co_change_count for r in results]
429 assert counts == sorted(counts, reverse=True)
430
431
432 class TestIntegrationBuildDepsGraph:
433 async def test_source_repo_not_in_workspace_returns_empty(
434 self, db_session: AsyncSession
435 ) -> None:
436 await db_session.flush()
437 g = await build_deps_graph(
438 db_session, "alice", "nonexistent", visible_to_user="alice"
439 )
440 assert g.nodes == []
441 assert g.edges == []
442
443 async def test_builds_nodes_from_symbol_history(
444 self, db_session: AsyncSession
445 ) -> None:
446 repo = await _db_repo(db_session, "alice", visibility="public")
447 c_id = _uid()
448 # Use dot-only addresses so _module_prefix produces clean 3-segment node IDs
449 await _db_symbol_index(
450 db_session,
451 repo.repo_id,
452 {
453 "musehub.services.ci.run": [_entry(c_id)],
454 "musehub.services.ci.cancel": [_entry(c_id)],
455 "musehub.services.auth.login": [_entry(c_id)],
456 },
457 )
458 await db_session.flush()
459
460 g = await build_deps_graph(
461 db_session, "alice", repo.repo_id, visible_to_user="alice"
462 )
463 node_ids = [n.id for n in g.nodes]
464 # _module_prefix("musehub.services.ci.run") → "musehub.services.ci"
465 assert "musehub.services.ci" in node_ids
466
467 async def test_no_symbol_history_returns_empty_graph(
468 self, db_session: AsyncSession
469 ) -> None:
470 repo = await _db_repo(db_session, "alice", visibility="public")
471 await db_session.flush()
472
473 g = await build_deps_graph(
474 db_session, "alice", repo.repo_id, visible_to_user="alice"
475 )
476 assert g.nodes == []
477
478
479 # ===========================================================================
480 # Layer 3 — E2E
481 # ===========================================================================
482
483
484 class TestE2ESymbolSearch:
485 async def test_search_page_200_with_query(
486 self,
487 client: AsyncClient,
488 auth_headers: StrDict,
489 db_session: AsyncSession,
490 ) -> None:
491 repo = await _db_repo(db_session, "testuser", visibility="public")
492 await _db_symbol_index(
493 db_session, repo.repo_id, {"file.py::MyFunc": [_entry(_uid())]}
494 )
495 await db_session.commit()
496
497 r = await client.get("/testuser/search?q=MyFunc", headers=auth_headers)
498 assert r.status_code == 200
499
500 async def test_search_page_200_empty_query(
501 self,
502 client: AsyncClient,
503 auth_headers: StrDict,
504 ) -> None:
505 r = await client.get("/testuser/search", headers=auth_headers)
506 assert r.status_code == 200
507
508 async def test_search_page_no_auth_public_owner(
509 self,
510 client: AsyncClient,
511 db_session: AsyncSession,
512 ) -> None:
513 """Public symbol search is accessible without auth token."""
514 repo = await _db_repo(db_session, "testuser", visibility="public")
515 await _db_symbol_index(
516 db_session, repo.repo_id, {"file.py::PubFn": [_entry(_uid())]}
517 )
518 await db_session.commit()
519
520 r = await client.get("/testuser/search?q=PubFn")
521 # UI route renders HTML; should succeed (200)
522 assert r.status_code == 200
523
524 async def test_search_returns_html(
525 self,
526 client: AsyncClient,
527 auth_headers: StrDict,
528 ) -> None:
529 r = await client.get("/testuser/search?q=foo", headers=auth_headers)
530 assert r.status_code == 200
531 assert "text/html" in r.headers.get("content-type", "")
532
533
534 # ===========================================================================
535 # Layer 4 — Stress
536 # ===========================================================================
537
538
539 class TestStress:
540 async def test_search_across_10_repos(self, db_session: AsyncSession) -> None:
541 for i in range(10):
542 repo = await _db_repo(db_session, "alice", name=f"repo-{i}", visibility="public")
543 history = {f"file.py::Sym{i}_{j}": [_entry(_uid())] for j in range(10)}
544 await _db_symbol_index(db_session, repo.repo_id, history)
545 await db_session.flush()
546
547 results = await search_symbol_across_repos(
548 db_session, "alice", "Sym", limit=30, visible_to_user="alice"
549 )
550 assert len(results) <= 30
551
552 async def test_concurrent_workspace_blast_risk(
553 self, db_session: AsyncSession
554 ) -> None:
555 repo = await _db_repo(db_session, "alice", visibility="public")
556 history = {f"file.py::sym_{i}": [_entry(_uid())] * 3 for i in range(30)}
557 await _db_symbol_index(db_session, repo.repo_id, history)
558 await db_session.flush()
559
560 results = await asyncio.gather(
561 *[
562 workspace_blast_risk_top_n(db_session, "alice", top_n=10, visible_to_user="alice")
563 for _ in range(5)
564 ]
565 )
566 assert all(len(r) <= 10 for r in results)
567
568 async def test_blast_risk_100_symbols(self, db_session: AsyncSession) -> None:
569 repo = await _db_repo(db_session, "alice", visibility="public")
570 history = {
571 f"musehub.services.mod_{i}::fn_{j}": [_entry(_uid())] * (i + 1)
572 for i in range(10)
573 for j in range(10)
574 }
575 await _db_symbol_index(db_session, repo.repo_id, history)
576 await db_session.flush()
577
578 results = await workspace_blast_risk_top_n(
579 db_session, "alice", top_n=20, visible_to_user="alice"
580 )
581 assert len(results) == 20
582
583
584 # ===========================================================================
585 # Layer 5 — Data Integrity
586 # ===========================================================================
587
588
589 class TestDataIntegrity:
590 async def test_search_results_sorted_by_co_change_desc(
591 self, db_session: AsyncSession
592 ) -> None:
593 repo = await _db_repo(db_session, "alice", visibility="public")
594 history = {
595 "file.py::Rarely": [_entry(_uid())],
596 "file.py::Often": [_entry(_uid())] * 8,
597 "file.py::Medium": [_entry(_uid())] * 3,
598 }
599 await _db_symbol_index(db_session, repo.repo_id, history)
600 await db_session.flush()
601
602 results = await search_symbol_across_repos(
603 db_session, "alice", "file.py", visible_to_user="alice"
604 )
605 counts = [r.co_change_count for r in results]
606 assert counts == sorted(counts, reverse=True)
607
608 async def test_blast_risk_top_n_hard_cap(
609 self, db_session: AsyncSession
610 ) -> None:
611 repo = await _db_repo(db_session, "alice", visibility="public")
612 history = {f"file.py::sym_{i}": [_entry(_uid())] for i in range(50)}
613 await _db_symbol_index(db_session, repo.repo_id, history)
614 await db_session.flush()
615
616 results = await workspace_blast_risk_top_n(
617 db_session, "alice", top_n=10, visible_to_user="alice"
618 )
619 assert len(results) == 10
620
621 async def test_cross_repo_match_fields_populated(
622 self, db_session: AsyncSession
623 ) -> None:
624 repo = await _db_repo(db_session, "alice", visibility="public")
625 c_id = _uid()
626 await _db_symbol_index(
627 db_session, repo.repo_id, {"a.b.c::MyFn": [_entry(c_id)]}
628 )
629 await db_session.flush()
630
631 results = await search_symbol_across_repos(
632 db_session, "alice", "MyFn", visible_to_user="alice"
633 )
634 assert len(results) == 1
635 m = results[0]
636 assert m.repo_id == repo.repo_id
637 assert m.address == "a.b.c::MyFn"
638 assert m.last_op in ("add", "modify", "delete")
639 assert m.co_change_count == 1
640
641 async def test_deps_graph_max_nodes_cap(
642 self, db_session: AsyncSession
643 ) -> None:
644 repo = await _db_repo(db_session, "alice", visibility="public")
645 history = {
646 f"module_{i}.sub.fn::Sym": [_entry(_uid())]
647 for i in range(80)
648 }
649 await _db_symbol_index(db_session, repo.repo_id, history)
650 await db_session.flush()
651
652 g = await build_deps_graph(
653 db_session, "alice", repo.repo_id,
654 visible_to_user="alice", max_nodes=60
655 )
656 assert len(g.nodes) <= 60
657
658
659 # ===========================================================================
660 # Layer 6 — Security
661 # ===========================================================================
662
663
664 class TestSecurity:
665 async def test_private_repo_symbols_invisible_to_non_owner(
666 self, db_session: AsyncSession
667 ) -> None:
668 repo = await _db_repo(db_session, "alice", visibility="private")
669 await _db_symbol_index(
670 db_session, repo.repo_id, {"secret.py::SecretKey": [_entry(_uid())]}
671 )
672 await db_session.flush()
673
674 results = await search_symbol_across_repos(
675 db_session, "alice", "SecretKey", visible_to_user="bob"
676 )
677 assert results == []
678
679 async def test_unauthenticated_only_sees_public(
680 self, db_session: AsyncSession
681 ) -> None:
682 pub_repo = await _db_repo(db_session, "alice", visibility="public")
683 priv_repo = await _db_repo(db_session, "alice", visibility="private")
684 c1, c2 = _uid(), _uid()
685 await _db_symbol_index(db_session, pub_repo.repo_id, {"pub.py::PubFn": [_entry(c1)]})
686 await _db_symbol_index(db_session, priv_repo.repo_id, {"priv.py::PrivFn": [_entry(c2)]})
687 await db_session.flush()
688
689 results = await search_symbol_across_repos(
690 db_session, "alice", "Fn", visible_to_user=None
691 )
692 addresses = [r.address for r in results]
693 assert "pub.py::PubFn" in addresses
694 assert "priv.py::PrivFn" not in addresses
695
696 async def test_blast_risk_private_repo_invisible_to_others(
697 self, db_session: AsyncSession
698 ) -> None:
699 priv = await _db_repo(db_session, "alice", visibility="private")
700 await _db_symbol_index(
701 db_session, priv.repo_id, {"file.py::Hidden": [_entry(_uid())] * 10}
702 )
703 await db_session.flush()
704
705 results = await workspace_blast_risk_top_n(
706 db_session, "alice", top_n=20, visible_to_user="bob"
707 )
708 assert all(r.repo_id != priv.repo_id for r in results)
709
710 async def test_cross_repo_impact_private_source_invisible(
711 self, db_session: AsyncSession
712 ) -> None:
713 """cross_repo_impact returns None when source repo is private and caller is not owner."""
714 priv = await _db_repo(db_session, "alice", visibility="private")
715 c_id = _uid()
716 await _db_symbol_index(
717 db_session, priv.repo_id, {"file.py::Fn": [_entry(c_id)]}
718 )
719 await db_session.flush()
720
721 result = await cross_repo_impact(
722 db_session, "alice", priv.repo_id, "file.py::Fn",
723 visible_to_user="bob"
724 )
725 # bob can't see alice's private repo → source_repo is None → returns None
726 assert result is None
727
728
729 # ===========================================================================
730 # Layer 7 — Performance
731 # ===========================================================================
732
733
734 class TestPerformance:
735 async def test_search_across_5_repos_under_300ms(
736 self, db_session: AsyncSession
737 ) -> None:
738 for i in range(5):
739 repo = await _db_repo(db_session, "alice", name=f"perf-{i}", visibility="public")
740 history = {f"file.py::Sym{i}_{j}": [_entry(_uid())] for j in range(20)}
741 await _db_symbol_index(db_session, repo.repo_id, history)
742 await db_session.flush()
743
744 start = time.perf_counter()
745 results = await search_symbol_across_repos(
746 db_session, "alice", "Sym", limit=30, visible_to_user="alice"
747 )
748 elapsed = time.perf_counter() - start
749
750 assert elapsed < 0.3, f"search took {elapsed:.3f}s, expected <0.3s"
751 assert len(results) <= 30
752
753 async def test_workspace_blast_risk_50_symbols_under_200ms(
754 self, db_session: AsyncSession
755 ) -> None:
756 repo = await _db_repo(db_session, "alice", visibility="public")
757 history = {f"file.py::sym_{i}": [_entry(_uid())] * (i % 5 + 1) for i in range(50)}
758 await _db_symbol_index(db_session, repo.repo_id, history)
759 await db_session.flush()
760
761 start = time.perf_counter()
762 results = await workspace_blast_risk_top_n(
763 db_session, "alice", top_n=20, visible_to_user="alice"
764 )
765 elapsed = time.perf_counter() - start
766
767 assert elapsed < 0.2, f"blast risk took {elapsed:.3f}s, expected <0.2s"
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 155 days ago