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