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