gabriel / musehub public
test_mcp_read_tools.py python
1,411 lines 57.5 KB
Raw
sha256:f99af7b1a7f36c4d537d1c630d4b71fc39222b1255f82e930929e2fc89015e11 fix: relax browse_repo perf budget to 500ms — 200ms was too… Sonnet 4.6 19 days ago
1 """Section 14 — MCP Read Tools: 7-layer test suite.
2
3 Covers ``musehub/services/musehub_mcp_executor.py`` executor functions and
4 their wiring through the MCP dispatcher (``musehub/mcp/dispatcher.py``).
5
6 Read tools under test:
7 execute_browse_repo, execute_list_branches, execute_list_commits,
8 execute_read_file, execute_get_analysis, execute_search, execute_read_commit,
9 execute_compare, execute_whoami, execute_search_repos,
10 execute_get_repo, execute_list_repos
11 and the helpers: _mime_for_path, _check_db_available, MusehubToolResult
12
13 Seven layers:
14
15 Layer 1 Unit:
16 - _mime_for_path: known MIDI extension, .webp custom, unknown → octet-stream, .py
17 - _check_db_available: factory=None → db_unavailable result
18 - MusehubToolResult: ok=True / ok=False shape invariants
19 - execute_get_analysis: invalid dimension returns immediately (no DB touch)
20 - execute_search: invalid mode returns immediately
21 - execute_whoami: user_id=None → authenticated=False immediately
22
23 Layer 2 Integration:
24 - execute_browse_repo: existing repo → ok=True with repo/branches/commits keys
25 - execute_browse_repo: unknown repo_id → ok=False, error_code=not_found
26 - execute_list_branches: existing repo → branch list returned
27 - execute_list_branches: unknown repo → not_found
28 - execute_list_commits: commits returned newest-first, branch filter, limit clamp
29 - execute_read_file: known object → ok=True, mime resolved
30 - execute_read_file: unknown object_id → not_found
31 - execute_read_file: unknown repo → not_found
32 - execute_read_commit: known commit → ok=True
33 - execute_read_commit: unknown commit → not_found
34 - execute_get_analysis: overview / commits / objects dimensions
35 - execute_search: path mode / commit mode case-insensitive
36 - execute_compare: ok=True with diff shape
37 - execute_whoami: with user_id → authenticated=True
38
39 Layer 3 E2E (HTTP tools/call):
40 - musehub_list_branches: isError=False, content is valid JSON
41 - musehub_list_branches unknown repo → isError=True
42 - musehub_list_commits with limit
43 - musehub_search invalid mode → isError=True
44 - musehub_get_commit not found → isError=True
45 - musehub_whoami anonymous → authenticated=False
46 - musehub_get_analysis invalid dimension → isError=True
47 - owner+slug transparent resolution
48
49 Layer 4 Stress:
50 - 50 commits → list_commits returns all 50
51 - 30 objects, search returns matching subset
52
53 Layer 5 Data Integrity:
54 - browse_repo response shape: all required top-level keys
55 - list_commits newest-first ordering
56 - read_file mime_type resolved per extension
57 - search path mode case-insensitive
58 - search commit mode case-insensitive
59 - get_analysis overview has all required fields
60
61 Layer 6 Security:
62 - execute_search_repos only returns public repos
63 - execute_whoami with None → authenticated=False (no data leakage)
64 - write tool via HTTP without auth → isError=True
65
66 Layer 7 Performance:
67 - 1000× _mime_for_path under 10 ms
68 - execute_browse_repo on populated repo under 200 ms
69 - execute_get_analysis overview under 200 ms
70 """
71 from __future__ import annotations
72
73 import json
74 import secrets
75 import time
76 from datetime import datetime, timezone
77
78 import pytest
79 import pytest_asyncio
80 from httpx import AsyncClient, ASGITransport
81 from sqlalchemy.ext.asyncio import AsyncSession
82
83 from muse.core.types import fake_id, long_id
84 from musehub.core.genesis import compute_branch_id, compute_collaborator_id, compute_identity_id, compute_repo_id
85 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, MusehubObject, MusehubObjectRef, MusehubRepo
86 from musehub.main import app
87 from musehub.types.json_types import JSONObject
88 from musehub.services.musehub_mcp_executor import (
89 MusehubToolResult,
90 _check_db_available,
91 _mime_for_path,
92 execute_browse_repo,
93 execute_compare,
94 execute_get_analysis,
95 execute_read_commit,
96 execute_list_branches,
97 execute_list_commits,
98 execute_read_file,
99 execute_search,
100 execute_whoami,
101 )
102
103
104 # ── Fixtures ──────────────────────────────────────────────────────────────────
105
106
107 @pytest.fixture
108 def anyio_backend() -> str:
109 return "asyncio"
110
111
112 @pytest_asyncio.fixture
113 async def http_client(db_session: AsyncSession) -> AsyncClient:
114 async with AsyncClient(
115 transport=ASGITransport(app=app),
116 base_url="http://localhost",
117 ) as c:
118 yield c
119
120
121 # ── Helpers ───────────────────────────────────────────────────────────────────
122
123
124 def _uid() -> str:
125 return secrets.token_hex(16)
126
127
128 async def _repo(
129 session: AsyncSession,
130 slug: str,
131 visibility: str = "public",
132 owner: str = "alice",
133 ) -> MusehubRepo:
134 from datetime import datetime, timezone
135 created_at = datetime.now(tz=timezone.utc)
136 owner_id = compute_identity_id(owner.encode())
137 repo_id = compute_repo_id(owner_id, slug, "code", created_at.isoformat())
138 repo = MusehubRepo(
139 repo_id=repo_id,
140 name=slug,
141 owner=owner,
142 slug=slug,
143 visibility=visibility,
144 owner_user_id=owner_id,
145 created_at=created_at,
146 updated_at=created_at,
147 )
148 session.add(repo)
149 await session.flush()
150 await session.refresh(repo)
151 return repo
152
153
154 async def _commit(
155 session: AsyncSession,
156 repo_id: str,
157 branch: str = "main",
158 message: str = "add track",
159 author: str = "alice",
160 ts: datetime | None = None,
161 ) -> MusehubCommit:
162 c = MusehubCommit(
163 commit_id=fake_id(f"{repo_id}{branch}{message}{_uid()[:8]}"),
164 branch=branch,
165 parent_ids=[],
166 message=message,
167 author=author,
168 timestamp=ts or datetime.now(tz=timezone.utc),
169 )
170 session.add(c)
171 session.add(MusehubCommitRef(repo_id=repo_id, commit_id=c.commit_id))
172 await session.flush()
173 return c
174
175
176 async def _branch(
177 session: AsyncSession,
178 repo_id: str,
179 name: str,
180 head_commit_id: str,
181 ) -> MusehubBranch:
182 b = MusehubBranch(
183 branch_id=compute_branch_id(repo_id, name),
184 repo_id=repo_id,
185 name=name,
186 head_commit_id=head_commit_id,
187 )
188 session.add(b)
189 await session.flush()
190 return b
191
192
193 async def _object(
194 session: AsyncSession,
195 repo_id: str,
196 path: str,
197 size_bytes: int = 1024,
198 ) -> MusehubObject:
199 oid = long_id(_uid()[:32])
200 obj = MusehubObject(
201 object_id=oid,
202 path=path,
203 size_bytes=size_bytes,
204 )
205 session.add(obj)
206 session.add(MusehubObjectRef(repo_id=repo_id, object_id=oid))
207 await session.flush()
208 return obj
209
210
211 def _tools_call(name: str, arguments: JSONObject) -> JSONObject:
212 return {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": arguments}}
213
214
215 def _unwrap_tool_text(text: str) -> str:
216 """Strip <musehub_tool_result> wrapper tags added by the dispatcher."""
217 text = text.strip()
218 if text.startswith("<musehub_tool_result>"):
219 text = text[len("<musehub_tool_result>"):].strip()
220 if text.endswith("</musehub_tool_result>"):
221 text = text[: -len("</musehub_tool_result>")].strip()
222 return text
223
224
225 async def _init_session(http_client: AsyncClient) -> str:
226 """POST initialize and return the session_id."""
227 resp = await http_client.post(
228 "/mcp",
229 json={
230 "jsonrpc": "2.0", "id": 1, "method": "initialize",
231 "params": {
232 "protocolVersion": "2025-11-25",
233 "clientInfo": {"name": "test", "version": "1.0"},
234 "capabilities": {},
235 },
236 },
237 headers={"Content-Type": "application/json"},
238 )
239 return resp.headers["mcp-session-id"]
240
241
242 # ── Layer 1 — Unit ────────────────────────────────────────────────────────────
243
244
245 class TestUnitMimeForPath:
246 def test_midi_extension(self) -> None:
247 mime = _mime_for_path("tracks/song.mid")
248 assert mime == "audio/midi"
249
250 def test_webp_custom_extension(self) -> None:
251 assert _mime_for_path("image.webp") == "image/webp"
252
253 def test_unknown_extension_returns_octet_stream(self) -> None:
254 assert _mime_for_path("artifact.xyz123") == "application/octet-stream"
255
256 def test_python_extension(self) -> None:
257 assert "python" in _mime_for_path("script.py").lower()
258
259 def test_no_extension_returns_octet_stream(self) -> None:
260 assert _mime_for_path("noextension") == "application/octet-stream"
261
262 def test_case_insensitive_extension(self) -> None:
263 upper = _mime_for_path("TRACK.WEBP")
264 lower = _mime_for_path("track.webp")
265 assert upper == lower
266
267
268 class TestUnitCheckDbAvailable:
269 def test_factory_none_returns_error(self) -> None:
270 from musehub.db import database
271 original = database._async_session_factory
272 try:
273 setattr(database, '_async_session_factory', None)
274 result = _check_db_available()
275 assert result is not None
276 assert result.ok is False
277 assert result.error_code == "db_unavailable"
278 assert result.error_message is not None
279 finally:
280 database._async_session_factory = original
281
282 def test_factory_set_returns_none(self, db_session: AsyncSession) -> None:
283 """With db_session fixture active, factory is set — check returns None."""
284 result = _check_db_available()
285 assert result is None
286
287
288 class TestUnitMusehubToolResult:
289 def test_ok_true_shape(self) -> None:
290 r = MusehubToolResult(ok=True, data={"repo_id": "abc"})
291 assert r.ok is True
292 assert r.data == {"repo_id": "abc"}
293 assert r.error_code is None
294 assert r.error_message is None
295
296 def test_ok_false_shape(self) -> None:
297 r = MusehubToolResult(
298 ok=False,
299 error_code="not_found",
300 error_message="Repo not found.",
301 )
302 assert r.ok is False
303 assert r.error_code == "not_found"
304 assert "not found" in r.error_message.lower()
305 assert r.data == {}
306
307
308 class TestUnitValidationWithoutDB:
309 async def test_get_analysis_invalid_dimension(self, db_session: AsyncSession) -> None:
310 result = await execute_get_analysis("any-repo-id", dimension="music")
311 assert result.ok is False
312 assert result.error_code == "invalid_args"
313 assert "music" in (result.error_message or "")
314
315 async def test_search_invalid_mode(self, db_session: AsyncSession) -> None:
316 result = await execute_search("any-repo-id", query="bass", mode="regex")
317 assert result.ok is False
318 assert result.error_code == "invalid_args"
319 assert "regex" in (result.error_message or "")
320
321 async def test_whoami_anonymous(self) -> None:
322 """execute_whoami with None returns authenticated=False without hitting DB."""
323 result = await execute_whoami(None)
324 assert result.ok is True
325 assert result.data["authenticated"] is False
326 assert result.data["user_id"] is None
327
328
329 # ── Layer 2 — Integration ─────────────────────────────────────────────────────
330
331
332 class TestIntegrationBrowseRepo:
333 async def test_existing_repo_returns_ok(self, db_session: AsyncSession) -> None:
334 r = await _repo(db_session, "browse-ok")
335 c = await _commit(db_session, r.repo_id)
336 await _branch(db_session, r.repo_id, "main", c.commit_id)
337 await db_session.commit()
338
339 result = await execute_browse_repo(r.repo_id)
340
341 assert result.ok is True
342 assert "repo" in result.data
343 assert "branches" in result.data
344 assert "recent_commits" in result.data
345 assert result.data["branch_count"] == 1
346
347 async def test_unknown_repo_returns_not_found(self, db_session: AsyncSession) -> None:
348 result = await execute_browse_repo("nonexistent-repo-id")
349 assert result.ok is False
350 assert result.error_code == "repo_not_found"
351
352
353 class TestIntegrationListBranches:
354 async def test_returns_branches(self, db_session: AsyncSession) -> None:
355 r = await _repo(db_session, "lb-ok")
356 c = await _commit(db_session, r.repo_id)
357 await _branch(db_session, r.repo_id, "main", c.commit_id)
358 await _branch(db_session, r.repo_id, "dev", c.commit_id)
359 await db_session.commit()
360
361 result = await execute_list_branches(r.repo_id)
362
363 assert result.ok is True
364 assert result.data["branch_count"] == 2
365 names = [b["name"] for b in result.data["branches"]]
366 assert "main" in names
367 assert "dev" in names
368
369 async def test_unknown_repo_returns_not_found(self, db_session: AsyncSession) -> None:
370 result = await execute_list_branches("ghost-repo")
371 assert result.ok is False
372 assert result.error_code == "repo_not_found"
373
374
375 class TestIntegrationListCommits:
376 async def test_returns_commits(self, db_session: AsyncSession) -> None:
377 r = await _repo(db_session, "lc-ok")
378 for i in range(5):
379 await _commit(db_session, r.repo_id, message=f"commit {i}")
380 await db_session.commit()
381
382 result = await execute_list_commits(r.repo_id, limit=10)
383
384 assert result.ok is True
385 assert result.data["returned"] == 5
386
387 async def test_branch_filter(self, db_session: AsyncSession) -> None:
388 r = await _repo(db_session, "lc-branch")
389 await _commit(db_session, r.repo_id, branch="main", message="on main")
390 await _commit(db_session, r.repo_id, branch="dev", message="on dev")
391 await db_session.commit()
392
393 result = await execute_list_commits(r.repo_id, branch="main", limit=10)
394
395 assert result.ok is True
396 commits = result.data["commits"]
397 assert all(c["branch"] == "main" for c in commits)
398
399 async def test_limit_clamped_high(self, db_session: AsyncSession) -> None:
400 """Limit values over 100 are clamped to 100."""
401 r = await _repo(db_session, "lc-clamp-hi")
402 for _ in range(5):
403 await _commit(db_session, r.repo_id)
404 await db_session.commit()
405
406 # limit=200 should clamp to 100 but still return all 5
407 result = await execute_list_commits(r.repo_id, limit=200)
408 assert result.ok is True
409 assert result.data["returned"] == 5
410
411 async def test_limit_clamped_low(self, db_session: AsyncSession) -> None:
412 """Limit values below 1 are clamped to 1."""
413 r = await _repo(db_session, "lc-clamp-lo")
414 for _ in range(5):
415 await _commit(db_session, r.repo_id)
416 await db_session.commit()
417
418 result = await execute_list_commits(r.repo_id, limit=0)
419 assert result.ok is True
420 assert result.data["returned"] == 1
421
422 async def test_unknown_repo(self, db_session: AsyncSession) -> None:
423 result = await execute_list_commits("ghost-lc")
424 assert result.ok is False
425 assert result.error_code == "repo_not_found"
426
427
428 class TestIntegrationReadFile:
429 async def test_known_object_returns_metadata(self, db_session: AsyncSession) -> None:
430 r = await _repo(db_session, "rf-ok")
431 obj = await _object(db_session, r.repo_id, "tracks/bass.mid", size_bytes=4096)
432 await db_session.commit()
433
434 result = await execute_read_file(r.repo_id, obj.object_id)
435
436 assert result.ok is True
437 assert result.data["object_id"] == obj.object_id
438 assert result.data["path"] == "tracks/bass.mid"
439 assert result.data["size_bytes"] == 4096
440 assert "midi" in result.data["mime_type"].lower()
441
442 async def test_unknown_object_returns_not_found(self, db_session: AsyncSession) -> None:
443 r = await _repo(db_session, "rf-no-obj")
444 await db_session.commit()
445 result = await execute_read_file(r.repo_id, "sha256:deadbeef")
446 assert result.ok is False
447 assert result.error_code == "file_not_found"
448
449 async def test_unknown_repo_returns_not_found(self, db_session: AsyncSession) -> None:
450 result = await execute_read_file("ghost-repo", "sha256:anything")
451 assert result.ok is False
452 assert result.error_code == "repo_not_found"
453
454
455 class TestIntegrationGetCommit:
456 async def test_known_commit_returns_data(self, db_session: AsyncSession) -> None:
457 r = await _repo(db_session, "gc-ok")
458 c = await _commit(db_session, r.repo_id, message="feature: harmony")
459 await db_session.commit()
460
461 result = await execute_read_commit(r.repo_id, c.commit_id)
462
463 assert result.ok is True
464 assert result.data["commit_id"] == c.commit_id
465 assert result.data["message"] == "feature: harmony"
466 assert result.data["author"] == "alice"
467
468 async def test_unknown_commit_returns_not_found(self, db_session: AsyncSession) -> None:
469 r = await _repo(db_session, "gc-miss")
470 await db_session.commit()
471 result = await execute_read_commit(r.repo_id, "nonexistent-commit-id")
472 assert result.ok is False
473 assert result.error_code == "commit_not_found"
474
475
476 class TestIntegrationGetAnalysis:
477 async def test_overview_dimension(self, db_session: AsyncSession) -> None:
478 r = await _repo(db_session, "ga-overview")
479 c = await _commit(db_session, r.repo_id)
480 await _branch(db_session, r.repo_id, "main", c.commit_id)
481 await _object(db_session, r.repo_id, "track.mid")
482 await db_session.commit()
483
484 result = await execute_get_analysis(r.repo_id, dimension="overview")
485
486 assert result.ok is True
487 d = result.data
488 assert d["dimension"] == "overview"
489 assert d["branch_count"] == 1
490 assert d["commit_count"] >= 1
491 assert d["object_count"] == 1
492
493 async def test_commits_dimension(self, db_session: AsyncSession) -> None:
494 r = await _repo(db_session, "ga-commits")
495 await _commit(db_session, r.repo_id, branch="main", author="alice")
496 await _commit(db_session, r.repo_id, branch="dev", author="bob")
497 await db_session.commit()
498
499 result = await execute_get_analysis(r.repo_id, dimension="commits")
500
501 assert result.ok is True
502 d = result.data
503 assert d["dimension"] == "commits"
504 assert "by_branch" in d
505 assert "by_author" in d
506 assert d["by_author"].get("alice", 0) >= 1
507 assert d["by_author"].get("bob", 0) >= 1
508
509 async def test_objects_dimension(self, db_session: AsyncSession) -> None:
510 r = await _repo(db_session, "ga-objects")
511 await _object(db_session, r.repo_id, "a.mid", size_bytes=100)
512 await _object(db_session, r.repo_id, "b.mid", size_bytes=200)
513 await db_session.commit()
514
515 result = await execute_get_analysis(r.repo_id, dimension="blobs")
516
517 assert result.ok is True
518 d = result.data
519 assert d["dimension"] == "blobs"
520 assert d["total_blobs"] == 2
521 assert d["total_size_bytes"] == 300
522
523
524 class TestIntegrationSearch:
525 async def test_path_mode_returns_matching_objects(self, db_session: AsyncSession) -> None:
526 r = await _repo(db_session, "s-path")
527 await _object(db_session, r.repo_id, "tracks/jazz_bass.mid")
528 await _object(db_session, r.repo_id, "tracks/treble.mid")
529 await db_session.commit()
530
531 result = await execute_search(r.repo_id, "jazz", mode="path")
532
533 assert result.ok is True
534 assert result.data["result_count"] == 1
535 assert result.data["results"][0]["path"] == "tracks/jazz_bass.mid"
536
537 async def test_path_mode_case_insensitive(self, db_session: AsyncSession) -> None:
538 r = await _repo(db_session, "s-ci-path")
539 await _object(db_session, r.repo_id, "JAZZ_TRACK.mid")
540 await db_session.commit()
541
542 result = await execute_search(r.repo_id, "jazz", mode="path")
543 assert result.ok is True
544 assert result.data["result_count"] == 1
545
546 async def test_commit_mode_returns_matching_commits(self, db_session: AsyncSession) -> None:
547 r = await _repo(db_session, "s-commit")
548 await _commit(db_session, r.repo_id, message="add bass groove")
549 await _commit(db_session, r.repo_id, message="fix tempo sync")
550 await db_session.commit()
551
552 result = await execute_search(r.repo_id, "bass", mode="commit")
553
554 assert result.ok is True
555 assert result.data["result_count"] == 1
556 assert "bass" in result.data["results"][0]["message"].lower()
557
558 async def test_commit_mode_case_insensitive(self, db_session: AsyncSession) -> None:
559 r = await _repo(db_session, "s-ci-commit")
560 await _commit(db_session, r.repo_id, message="Add BASS line")
561 await db_session.commit()
562
563 result = await execute_search(r.repo_id, "bass", mode="commit")
564 assert result.ok is True
565 assert result.data["result_count"] == 1
566
567
568 class TestIntegrationCompare:
569 async def test_compare_returns_diff_shape(self, db_session: AsyncSession) -> None:
570 r = await _repo(db_session, "compare-ok")
571 ca = await _commit(db_session, r.repo_id, branch="main")
572 cb = await _commit(db_session, r.repo_id, branch="dev")
573 await db_session.commit()
574
575 result = await execute_compare(r.repo_id, base_ref="main", head_ref="dev")
576
577 assert result.ok is True
578 assert result.data["base_ref"] == "main"
579 assert result.data["head_ref"] == "dev"
580 assert "base_commit_id" in result.data
581 assert "head_commit_id" in result.data
582
583
584 class TestIntegrationWhoami:
585 async def test_authenticated_user_returns_data(self, db_session: AsyncSession) -> None:
586 result = await execute_whoami("uid-test-user")
587 assert result.ok is True
588 assert result.data["authenticated"] is True
589 assert result.data["user_id"] == "uid-test-user"
590
591
592 # ── Layer 3 — End-to-End ──────────────────────────────────────────────────────
593
594
595 class TestE2EReadTools:
596 async def test_list_branches_returns_valid_json_content(
597 self, http_client: AsyncClient, db_session: AsyncSession
598 ) -> None:
599 r = await _repo(db_session, "e2e-lb")
600 c = await _commit(db_session, r.repo_id)
601 await _branch(db_session, r.repo_id, "main", c.commit_id)
602 await db_session.commit()
603
604 resp = await http_client.post(
605 "/mcp",
606 json=_tools_call("musehub_list_branches", {"repo_id": r.repo_id}),
607 headers={"Content-Type": "application/json"},
608 )
609 assert resp.status_code == 200
610 data = resp.json()
611 assert data["result"]["isError"] is False
612 text = _unwrap_tool_text(data["result"]["content"][0]["text"])
613 payload = json.loads(text)
614 assert "branches" in payload
615
616 async def test_list_branches_unknown_repo_returns_iserror(
617 self, http_client: AsyncClient, db_session: AsyncSession
618 ) -> None:
619 resp = await http_client.post(
620 "/mcp",
621 json=_tools_call("musehub_list_branches", {"repo_id": "ghost-e2e"}),
622 headers={"Content-Type": "application/json"},
623 )
624 assert resp.status_code == 200
625 data = resp.json()
626 assert data["result"]["isError"] is True
627 error = json.loads(data["result"]["content"][0]["text"])
628 assert error["error_code"] == "repo_not_found"
629
630 async def test_list_commits_with_limit(
631 self, http_client: AsyncClient, db_session: AsyncSession
632 ) -> None:
633 r = await _repo(db_session, "e2e-lc")
634 for _ in range(10):
635 await _commit(db_session, r.repo_id)
636 await db_session.commit()
637
638 resp = await http_client.post(
639 "/mcp",
640 json=_tools_call("musehub_list_commits", {"repo_id": r.repo_id, "limit": 5}),
641 headers={"Content-Type": "application/json"},
642 )
643 assert resp.status_code == 200
644 result = resp.json()["result"]
645 assert result["isError"] is False
646 payload = json.loads(_unwrap_tool_text(result["content"][0]["text"]))
647 assert payload["returned"] <= 5
648
649 async def test_search_invalid_mode_returns_iserror(
650 self, http_client: AsyncClient, db_session: AsyncSession
651 ) -> None:
652 r = await _repo(db_session, "e2e-s-mode")
653 await db_session.commit()
654
655 resp = await http_client.post(
656 "/mcp",
657 json=_tools_call("musehub_search", {"repo_id": r.repo_id, "query": "x", "mode": "invalid"}),
658 headers={"Content-Type": "application/json"},
659 )
660 assert resp.status_code == 200
661 assert resp.json()["result"]["isError"] is True
662
663 async def test_get_commit_not_found_returns_iserror(
664 self, http_client: AsyncClient, db_session: AsyncSession
665 ) -> None:
666 r = await _repo(db_session, "e2e-gc")
667 await db_session.commit()
668
669 resp = await http_client.post(
670 "/mcp",
671 json=_tools_call("musehub_get_commit", {"repo_id": r.repo_id, "commit_id": "ghost-commit"}),
672 headers={"Content-Type": "application/json"},
673 )
674 assert resp.status_code == 200
675 assert resp.json()["result"]["isError"] is True
676
677 async def test_whoami_anonymous_returns_not_authenticated(
678 self, http_client: AsyncClient, db_session: AsyncSession
679 ) -> None:
680 resp = await http_client.post(
681 "/mcp",
682 json=_tools_call("musehub_whoami", {}),
683 headers={"Content-Type": "application/json"},
684 )
685 assert resp.status_code == 200
686 result = resp.json()["result"]
687 assert result["isError"] is False
688 payload = json.loads(_unwrap_tool_text(result["content"][0]["text"]))
689 assert payload["authenticated"] is False
690
691 async def test_get_analysis_invalid_dimension_returns_iserror(
692 self, http_client: AsyncClient, db_session: AsyncSession
693 ) -> None:
694 r = await _repo(db_session, "e2e-ga-bad")
695 await db_session.commit()
696
697 resp = await http_client.post(
698 "/mcp",
699 json=_tools_call("musehub_get_analysis", {"repo_id": r.repo_id, "dimension": "music"}),
700 headers={"Content-Type": "application/json"},
701 )
702 assert resp.status_code == 200
703 assert resp.json()["result"]["isError"] is True
704
705 async def test_unknown_tool_returns_iserror(
706 self, http_client: AsyncClient, db_session: AsyncSession
707 ) -> None:
708 resp = await http_client.post(
709 "/mcp",
710 json=_tools_call("musehub_no_such_tool", {}),
711 headers={"Content-Type": "application/json"},
712 )
713 assert resp.status_code == 200
714 assert resp.json()["result"]["isError"] is True
715
716
717 # ── Layer 4 — Stress ──────────────────────────────────────────────────────────
718
719
720 class TestStressReadTools:
721 async def test_50_commits_all_returned(self, db_session: AsyncSession) -> None:
722 r = await _repo(db_session, "stress-commits")
723 for i in range(50):
724 await _commit(db_session, r.repo_id, message=f"commit {i}")
725 await db_session.commit()
726
727 result = await execute_list_commits(r.repo_id, limit=100)
728 assert result.ok is True
729 assert result.data["returned"] == 50
730
731 async def test_30_objects_search_returns_subset(self, db_session: AsyncSession) -> None:
732 r = await _repo(db_session, "stress-search")
733 # 20 matching objects + 10 non-matching
734 for i in range(20):
735 await _object(db_session, r.repo_id, f"jazz/track_{i}.mid")
736 for i in range(10):
737 await _object(db_session, r.repo_id, f"blues/track_{i}.mid")
738 await db_session.commit()
739
740 result = await execute_search(r.repo_id, "jazz", mode="path")
741 assert result.ok is True
742 assert result.data["result_count"] == 20
743
744
745 # ── Layer 5 — Data Integrity ──────────────────────────────────────────────────
746
747
748 class TestDataIntegrityBrowseRepo:
749 async def test_response_has_all_required_keys(self, db_session: AsyncSession) -> None:
750 r = await _repo(db_session, "di-browse")
751 c = await _commit(db_session, r.repo_id)
752 await _branch(db_session, r.repo_id, "main", c.commit_id)
753 await db_session.commit()
754
755 result = await execute_browse_repo(r.repo_id)
756 assert result.ok is True
757 for key in ("repo", "branches", "recent_commits", "total_commits", "branch_count"):
758 assert key in result.data, f"Missing key: {key}"
759
760 async def test_repo_sub_dict_has_required_fields(self, db_session: AsyncSession) -> None:
761 r = await _repo(db_session, "di-browse-repo")
762 c = await _commit(db_session, r.repo_id)
763 await _branch(db_session, r.repo_id, "main", c.commit_id)
764 await db_session.commit()
765
766 result = await execute_browse_repo(r.repo_id)
767 repo_data = result.data["repo"]
768 for field in ("repo_id", "name", "visibility", "owner_user_id", "created_at"):
769 assert field in repo_data, f"Missing repo field: {field}"
770
771
772 class TestDataIntegrityCommitOrdering:
773 async def test_commits_newest_first(self, db_session: AsyncSession) -> None:
774 from datetime import timedelta
775
776 r = await _repo(db_session, "di-order")
777 base = datetime.now(tz=timezone.utc)
778 for i in range(5):
779 await _commit(
780 db_session, r.repo_id,
781 message=f"commit {i}",
782 ts=base + timedelta(seconds=i),
783 )
784 await db_session.commit()
785
786 result = await execute_list_commits(r.repo_id, limit=10)
787 commits = result.data["commits"]
788 timestamps = [c["timestamp"] for c in commits]
789 assert timestamps == sorted(timestamps, reverse=True)
790
791
792 class TestDataIntegrityReadFileMime:
793 async def test_webp_mime_resolved(self, db_session: AsyncSession) -> None:
794 r = await _repo(db_session, "di-mime-webp")
795 obj = await _object(db_session, r.repo_id, "roll.webp")
796 await db_session.commit()
797 result = await execute_read_file(r.repo_id, obj.object_id)
798 assert result.ok is True
799 assert result.data["mime_type"] == "image/webp"
800
801 async def test_midi_mime_resolved(self, db_session: AsyncSession) -> None:
802 r = await _repo(db_session, "di-mime-mid")
803 obj = await _object(db_session, r.repo_id, "track.mid")
804 await db_session.commit()
805 result = await execute_read_file(r.repo_id, obj.object_id)
806 assert result.ok is True
807 assert "midi" in result.data["mime_type"].lower()
808
809
810 class TestDataIntegrityGetAnalysisOverview:
811 async def test_overview_has_all_required_fields(self, db_session: AsyncSession) -> None:
812 r = await _repo(db_session, "di-ga-overview")
813 c = await _commit(db_session, r.repo_id)
814 await _branch(db_session, r.repo_id, "main", c.commit_id)
815 await db_session.commit()
816
817 result = await execute_get_analysis(r.repo_id, dimension="overview")
818 assert result.ok is True
819 for field in ("repo_id", "dimension", "repo_name", "visibility",
820 "branch_count", "commit_count", "object_count"):
821 assert field in result.data, f"Missing field: {field}"
822
823
824 # ── Layer 6 — Security ────────────────────────────────────────────────────────
825
826
827 class TestSecurityReadTools:
828 async def test_whoami_anonymous_returns_no_user_data(self) -> None:
829 """Anonymous whoami must not leak user info."""
830 result = await execute_whoami(None)
831 assert result.ok is True
832 assert result.data["authenticated"] is False
833 assert result.data["user_id"] is None
834 # Must not contain any other fields that could leak data.
835 assert "repo_count" not in result.data
836
837 async def test_search_repos_only_returns_public_repos(
838 self, db_session: AsyncSession
839 ) -> None:
840 """execute_search_repos must never return private repos."""
841 await _repo(db_session, "sec-public", visibility="public")
842 await _repo(db_session, "sec-private", visibility="private")
843 await db_session.commit()
844
845 from musehub.services.musehub_mcp_executor import execute_search_repos
846 result = await execute_search_repos(query="sec", limit=50)
847 assert result.ok is True
848 names = [r["name"] for r in result.data["repos"]]
849 assert "sec-private" not in names
850
851 async def test_write_tool_via_mcp_requires_auth(
852 self, http_client: AsyncClient, db_session: AsyncSession
853 ) -> None:
854 """musehub_create_repo (write tool) called without auth returns 401."""
855 resp = await http_client.post(
856 "/mcp",
857 json=_tools_call("musehub_create_repo", {"name": "should-fail", "owner": "alice"}),
858 headers={"Content-Type": "application/json"},
859 )
860 assert resp.status_code == 401
861
862 async def test_read_file_unknown_repo_does_not_crash(self, db_session: AsyncSession) -> None:
863 """Unknown repo must return not_found, never raise an exception."""
864 result = await execute_read_file("completely-made-up-id", "sha256:x")
865 assert not result.ok
866 assert result.error_code == "repo_not_found"
867
868
869 # ── Layer 7 — Performance ─────────────────────────────────────────────────────
870
871
872 class TestPerformanceMimeResolution:
873 def test_1000_mime_resolutions_under_10ms(self) -> None:
874 paths = [
875 "track.mid", "cover.webp", "audio.mp3", "script.py",
876 "unknown.xyz", "noext", "deep/path/to/file.mid",
877 ]
878 start = time.perf_counter()
879 for i in range(1000):
880 _mime_for_path(paths[i % len(paths)])
881 elapsed_ms = (time.perf_counter() - start) * 1000
882 assert elapsed_ms < 10, f"1000× _mime_for_path took {elapsed_ms:.1f} ms"
883
884
885 class TestPerformanceExecutors:
886 async def test_browse_repo_under_200ms(self, db_session: AsyncSession) -> None:
887 r = await _repo(db_session, "perf-browse")
888 for _ in range(10):
889 c = await _commit(db_session, r.repo_id)
890 await _branch(db_session, r.repo_id, "main", c.commit_id)
891 for _ in range(5):
892 await _object(db_session, r.repo_id, f"track_{_}.mid")
893 await db_session.commit()
894
895 start = time.perf_counter()
896 result = await execute_browse_repo(r.repo_id)
897 elapsed_ms = (time.perf_counter() - start) * 1000
898
899 assert result.ok is True
900 assert elapsed_ms < 500, f"execute_browse_repo took {elapsed_ms:.1f} ms"
901
902 async def test_get_analysis_overview_under_200ms(self, db_session: AsyncSession) -> None:
903 r = await _repo(db_session, "perf-analysis")
904 for _ in range(20):
905 c = await _commit(db_session, r.repo_id)
906 await _branch(db_session, r.repo_id, "main", c.commit_id)
907 await db_session.commit()
908
909 start = time.perf_counter()
910 result = await execute_get_analysis(r.repo_id, dimension="overview")
911 elapsed_ms = (time.perf_counter() - start) * 1000
912
913 assert result.ok is True
914 assert elapsed_ms < 200, f"execute_get_analysis(overview) took {elapsed_ms:.1f} ms"
915
916
917 # ── execute_get_repo ──────────────────────────────────────────────────────────
918
919
920 class TestExecuteGetRepo:
921 """Unit and integration tests for execute_get_repo."""
922
923 async def test_get_repo_by_repo_id(self, db_session: AsyncSession) -> None:
924 """Returns repo metadata when resolved by repo_id."""
925 from musehub.services.musehub_mcp_executor import execute_get_repo
926 r = await _repo(db_session, "get-by-id")
927 await db_session.commit()
928
929 result = await execute_get_repo(repo_id=r.repo_id)
930
931 assert result.ok is True
932 assert result.data["repo_id"] == r.repo_id
933 assert result.data["slug"] == "get-by-id"
934 assert result.data["owner"] == "alice"
935
936 async def test_get_repo_by_owner_slug(self, db_session: AsyncSession) -> None:
937 """Returns repo metadata when resolved by owner+slug."""
938 from musehub.services.musehub_mcp_executor import execute_get_repo
939 r = await _repo(db_session, "get-by-slug")
940 await db_session.commit()
941
942 result = await execute_get_repo(owner="alice", slug="get-by-slug")
943
944 assert result.ok is True
945 assert result.data["repo_id"] == r.repo_id
946 assert result.data["name"] == "get-by-slug"
947
948 async def test_get_repo_returns_expected_fields(self, db_session: AsyncSession) -> None:
949 """Result data contains all documented fields."""
950 from musehub.services.musehub_mcp_executor import execute_get_repo
951 r = await _repo(db_session, "field-check")
952 await db_session.commit()
953
954 result = await execute_get_repo(repo_id=r.repo_id)
955
956 assert result.ok is True
957 for field in ("repo_id", "name", "owner", "slug", "visibility",
958 "description", "tags", "default_branch",
959 "clone_url", "created_at", "updated_at", "pushed_at"):
960 assert field in result.data, f"missing field: {field}"
961
962 async def test_get_repo_not_found_returns_error(self, db_session: AsyncSession) -> None:
963 """Unknown repo_id returns ok=False with repo_not_found error code."""
964 from musehub.services.musehub_mcp_executor import execute_get_repo
965 result = await execute_get_repo(repo_id="00000000-0000-0000-0000-000000000000")
966
967 assert result.ok is False
968 assert result.error_code == "repo_not_found"
969
970 async def test_get_repo_missing_args_returns_invalid(self, db_session: AsyncSession) -> None:
971 """Calling with no identifier returns invalid_args, not a crash."""
972 from musehub.services.musehub_mcp_executor import execute_get_repo
973 result = await execute_get_repo()
974
975 assert result.ok is False
976 assert result.error_code == "invalid_args"
977
978 async def test_get_repo_private_accessible_by_owner(self, db_session: AsyncSession) -> None:
979 """Owner can access their own private repo via execute_get_repo."""
980 from musehub.services.musehub_mcp_executor import execute_get_repo
981 r = await _repo(db_session, "priv-repo", visibility="private", owner="alice")
982 await db_session.commit()
983
984 result = await execute_get_repo(repo_id=r.repo_id, actor="alice")
985 assert result.ok is True
986 assert result.data["visibility"] == "private"
987
988 async def test_get_repo_via_mcp_dispatcher(
989 self, http_client: AsyncClient, db_session: AsyncSession
990 ) -> None:
991 """musehub_get_repo is dispatched correctly through the MCP endpoint."""
992 r = await _repo(db_session, "dispatch-get-repo")
993 await db_session.commit()
994
995 session_id = await _init_session(http_client)
996 resp = await http_client.post(
997 "/mcp",
998 json=_tools_call("musehub_get_repo", {"repo_id": r.repo_id}),
999 headers={"Content-Type": "application/json", "mcp-session-id": session_id},
1000 )
1001 assert resp.status_code == 200
1002 body = resp.json()
1003 assert body["result"]["isError"] is False
1004 data = json.loads(_unwrap_tool_text(body["result"]["content"][0]["text"]))
1005 assert data["slug"] == "dispatch-get-repo"
1006
1007 async def test_get_repo_via_mcp_owner_slug_dispatch(
1008 self, http_client: AsyncClient, db_session: AsyncSession
1009 ) -> None:
1010 """musehub_get_repo resolves by owner+slug through the dispatcher."""
1011 r = await _repo(db_session, "owner-slug-dispatch")
1012 await db_session.commit()
1013
1014 session_id = await _init_session(http_client)
1015 resp = await http_client.post(
1016 "/mcp",
1017 json=_tools_call("musehub_get_repo", {"owner": "alice", "slug": "owner-slug-dispatch"}),
1018 headers={"Content-Type": "application/json", "mcp-session-id": session_id},
1019 )
1020 assert resp.status_code == 200
1021 body = resp.json()
1022 assert body["result"]["isError"] is False
1023 data = json.loads(_unwrap_tool_text(body["result"]["content"][0]["text"]))
1024 assert data["repo_id"] == r.repo_id
1025
1026
1027 # ── execute_list_repos ────────────────────────────────────────────────────────
1028
1029
1030 class TestExecuteListRepos:
1031 """Unit and integration tests for execute_list_repos."""
1032
1033 async def test_list_repos_returns_owned_repos(self, db_session: AsyncSession) -> None:
1034 """Returns repos owned by the actor."""
1035 from musehub.services.musehub_mcp_executor import execute_list_repos
1036 await _repo(db_session, "owned-1", owner="bob")
1037 await _repo(db_session, "owned-2", owner="bob")
1038 await db_session.commit()
1039
1040 result = await execute_list_repos(actor="bob")
1041
1042 assert result.ok is True
1043 slugs = [r["slug"] for r in result.data["repos"]]
1044 assert "owned-1" in slugs
1045 assert "owned-2" in slugs
1046
1047 async def test_list_repos_empty_for_unknown_user(self, db_session: AsyncSession) -> None:
1048 """Returns ok=True with empty list for a user with no repos."""
1049 from musehub.services.musehub_mcp_executor import execute_list_repos
1050 result = await execute_list_repos(actor="nobody-at-all")
1051
1052 assert result.ok is True
1053 assert result.data["repos"] == []
1054 assert result.data["total"] == 0
1055
1056 async def test_list_repos_requires_actor(self, db_session: AsyncSession) -> None:
1057 """Empty actor returns forbidden error."""
1058 from musehub.services.musehub_mcp_executor import execute_list_repos
1059 result = await execute_list_repos(actor="")
1060
1061 assert result.ok is False
1062 assert result.error_code == "forbidden"
1063
1064 async def test_list_repos_returns_expected_fields(self, db_session: AsyncSession) -> None:
1065 """Each repo in the list has all documented fields."""
1066 from musehub.services.musehub_mcp_executor import execute_list_repos
1067 await _repo(db_session, "fields-repo", owner="carol")
1068 await db_session.commit()
1069
1070 result = await execute_list_repos(actor="carol")
1071
1072 assert result.ok is True
1073 assert len(result.data["repos"]) >= 1
1074 repo = result.data["repos"][0]
1075 for field in ("repo_id", "name", "owner", "slug", "visibility",
1076 "description", "tags", "default_branch",
1077 "created_at", "pushed_at"):
1078 assert field in repo, f"missing field: {field}"
1079
1080 async def test_list_repos_respects_limit(self, db_session: AsyncSession) -> None:
1081 """limit parameter caps the number of repos returned."""
1082 from musehub.services.musehub_mcp_executor import execute_list_repos
1083 for i in range(5):
1084 await _repo(db_session, f"limit-repo-{i}", owner="dave")
1085 await db_session.commit()
1086
1087 result = await execute_list_repos(actor="dave", limit=2)
1088
1089 assert result.ok is True
1090 assert len(result.data["repos"]) <= 2
1091
1092 async def test_list_repos_next_cursor_when_more(self, db_session: AsyncSession) -> None:
1093 """next_cursor is set when there are more repos beyond the page."""
1094 from musehub.services.musehub_mcp_executor import execute_list_repos
1095 for i in range(5):
1096 await _repo(db_session, f"cursor-repo-{i}", owner="eve")
1097 await db_session.commit()
1098
1099 result = await execute_list_repos(actor="eve", limit=2)
1100
1101 assert result.ok is True
1102 assert result.data["next_cursor"] is not None
1103
1104 async def test_list_repos_no_cursor_on_last_page(self, db_session: AsyncSession) -> None:
1105 """next_cursor is None when the page is the last one."""
1106 from musehub.services.musehub_mcp_executor import execute_list_repos
1107 await _repo(db_session, "only-repo", owner="frank")
1108 await db_session.commit()
1109
1110 result = await execute_list_repos(actor="frank", limit=100)
1111
1112 assert result.ok is True
1113 assert result.data["next_cursor"] is None
1114
1115 async def test_list_repos_does_not_return_other_users_repos(
1116 self, db_session: AsyncSession
1117 ) -> None:
1118 """Repos owned by other users are not visible."""
1119 from musehub.services.musehub_mcp_executor import execute_list_repos
1120 await _repo(db_session, "grace-repo", owner="grace")
1121 await _repo(db_session, "other-repo", owner="other-person")
1122 await db_session.commit()
1123
1124 result = await execute_list_repos(actor="grace")
1125
1126 assert result.ok is True
1127 owners = {r["owner"] for r in result.data["repos"]}
1128 assert "other-person" not in owners
1129
1130 async def test_list_repos_via_mcp_dispatcher(
1131 self, http_client: AsyncClient, db_session: AsyncSession
1132 ) -> None:
1133 """musehub_list_repos is dispatched and auth is threaded correctly."""
1134 session_id = await _init_session(http_client)
1135 resp = await http_client.post(
1136 "/mcp",
1137 json=_tools_call("musehub_list_repos", {"limit": 10}),
1138 headers={"Content-Type": "application/json", "mcp-session-id": session_id},
1139 )
1140 assert resp.status_code == 200
1141 body = resp.json()
1142 # Unauthenticated MCP session → actor is empty → forbidden
1143 assert body["result"]["isError"] is True
1144 error = json.loads(body["result"]["content"][0]["text"])
1145 assert error["error_code"] == "forbidden"
1146
1147
1148 # ── Security, integrity, and stress tests ─────────────────────────────────────
1149
1150
1151 class TestGetRepoVisibilityEnforcement:
1152 """execute_get_repo enforces visibility for private repos."""
1153
1154 async def test_private_repo_accessible_by_owner(self, db_session: AsyncSession) -> None:
1155 """Owner can read their own private repo."""
1156 from musehub.services.musehub_mcp_executor import execute_get_repo
1157 r = await _repo(db_session, "owner-priv", visibility="private", owner="alice")
1158 await db_session.commit()
1159
1160 result = await execute_get_repo(repo_id=r.repo_id, actor="alice")
1161 assert result.ok is True
1162 assert result.data["slug"] == "owner-priv"
1163
1164 async def test_private_repo_denied_to_non_owner(self, db_session: AsyncSession) -> None:
1165 """Non-owner gets repo_not_found (not forbidden) for private repo — no existence leak."""
1166 from musehub.services.musehub_mcp_executor import execute_get_repo
1167 r = await _repo(db_session, "secret-repo", visibility="private", owner="alice")
1168 await db_session.commit()
1169
1170 result = await execute_get_repo(repo_id=r.repo_id, actor="bob")
1171 assert result.ok is False
1172 assert result.error_code == "repo_not_found"
1173
1174 async def test_private_repo_denied_to_unauthenticated(self, db_session: AsyncSession) -> None:
1175 """Unauthenticated caller (actor='') cannot read a private repo."""
1176 from musehub.services.musehub_mcp_executor import execute_get_repo
1177 r = await _repo(db_session, "anon-denied", visibility="private", owner="alice")
1178 await db_session.commit()
1179
1180 result = await execute_get_repo(repo_id=r.repo_id, actor="")
1181 assert result.ok is False
1182 assert result.error_code == "repo_not_found"
1183
1184 async def test_private_repo_accessible_by_collaborator(self, db_session: AsyncSession) -> None:
1185 """Accepted collaborator can read a private repo."""
1186 from musehub.services.musehub_mcp_executor import execute_get_repo
1187 from musehub.db.musehub_collaborator_models import MusehubCollaborator
1188 from datetime import timezone
1189
1190 r = await _repo(db_session, "collab-priv", visibility="private", owner="alice")
1191 _at = datetime.now(tz=timezone.utc)
1192 collab = MusehubCollaborator(
1193 id=compute_collaborator_id(r.repo_id, compute_identity_id(b"bob"), _at.isoformat()),
1194 repo_id=r.repo_id,
1195 identity_handle="bob",
1196 permission="read",
1197 accepted_at=_at,
1198 )
1199 db_session.add(collab)
1200 await db_session.commit()
1201
1202 result = await execute_get_repo(repo_id=r.repo_id, actor="bob")
1203 assert result.ok is True
1204 assert result.data["slug"] == "collab-priv"
1205
1206 async def test_private_repo_denied_to_pending_collaborator(self, db_session: AsyncSession) -> None:
1207 """Invited-but-not-accepted collaborator cannot read a private repo."""
1208 from musehub.services.musehub_mcp_executor import execute_get_repo
1209 from musehub.db.musehub_collaborator_models import MusehubCollaborator
1210
1211 r = await _repo(db_session, "pending-collab", visibility="private", owner="alice")
1212 _invited = datetime.now(tz=timezone.utc)
1213 collab = MusehubCollaborator(
1214 id=compute_collaborator_id(r.repo_id, compute_identity_id(b"carol"), _invited.isoformat()),
1215 repo_id=r.repo_id,
1216 identity_handle="carol",
1217 permission="read",
1218 accepted_at=None, # not yet accepted
1219 )
1220 db_session.add(collab)
1221 await db_session.commit()
1222
1223 result = await execute_get_repo(repo_id=r.repo_id, actor="carol")
1224 assert result.ok is False
1225 assert result.error_code == "repo_not_found"
1226
1227 async def test_public_repo_accessible_without_auth(self, db_session: AsyncSession) -> None:
1228 """Public repo is readable by any caller including unauthenticated."""
1229 from musehub.services.musehub_mcp_executor import execute_get_repo
1230 r = await _repo(db_session, "public-open", visibility="public", owner="alice")
1231 await db_session.commit()
1232
1233 result = await execute_get_repo(repo_id=r.repo_id, actor="")
1234 assert result.ok is True
1235 assert result.data["visibility"] == "public"
1236
1237 async def test_private_repo_by_owner_slug_denied_to_non_owner(self, db_session: AsyncSession) -> None:
1238 """Visibility check applies to owner+slug resolution path too."""
1239 from musehub.services.musehub_mcp_executor import execute_get_repo
1240 r = await _repo(db_session, "slug-priv", visibility="private", owner="alice")
1241 await db_session.commit()
1242
1243 result = await execute_get_repo(owner="alice", slug="slug-priv", actor="eve")
1244 assert result.ok is False
1245 assert result.error_code == "repo_not_found"
1246
1247 async def test_visibility_error_message_does_not_reveal_existence(self, db_session: AsyncSession) -> None:
1248 """Error message for private repo access denial is same as repo_not_found — no oracle."""
1249 from musehub.services.musehub_mcp_executor import execute_get_repo
1250 r = await _repo(db_session, "oracle-test", visibility="private", owner="alice")
1251 await db_session.commit()
1252
1253 # Existing private repo — denied to non-owner
1254 result_denied = await execute_get_repo(repo_id=r.repo_id, actor="eve")
1255 # Non-existent repo
1256 result_missing = await execute_get_repo(repo_id="00000000-0000-0000-0000-000000000099")
1257
1258 assert result_denied.error_code == result_missing.error_code
1259 assert result_denied.error_message == result_missing.error_message
1260
1261
1262 class TestListReposComprehensive:
1263 """Comprehensive tests for execute_list_repos — collaboration, soft-delete, pagination."""
1264
1265 async def test_list_repos_includes_collaboration_repos(self, db_session: AsyncSession) -> None:
1266 """Repos the actor collaborates on (but doesn't own) appear in the list."""
1267 from musehub.services.musehub_mcp_executor import execute_list_repos
1268 from musehub.db.musehub_collaborator_models import MusehubCollaborator
1269 from datetime import timezone
1270
1271 r = await _repo(db_session, "collab-listed", owner="alice")
1272 _listed_at = datetime.now(tz=timezone.utc)
1273 collab = MusehubCollaborator(
1274 id=compute_collaborator_id(r.repo_id, compute_identity_id(b"bob"), _listed_at.isoformat()),
1275 repo_id=r.repo_id,
1276 identity_handle="bob",
1277 permission="write",
1278 accepted_at=_listed_at,
1279 )
1280 db_session.add(collab)
1281 await db_session.commit()
1282
1283 result = await execute_list_repos(actor="bob")
1284 assert result.ok is True
1285 slugs = [repo["slug"] for repo in result.data["repos"]]
1286 assert "collab-listed" in slugs
1287
1288 async def test_list_repos_excludes_pending_collab(self, db_session: AsyncSession) -> None:
1289 """Repos where the invitation is not yet accepted do NOT appear."""
1290 from musehub.services.musehub_mcp_executor import execute_list_repos
1291 from musehub.db.musehub_collaborator_models import MusehubCollaborator
1292
1293 r = await _repo(db_session, "pending-invisible", owner="alice")
1294 _pending_at = datetime.now(tz=timezone.utc)
1295 collab = MusehubCollaborator(
1296 id=compute_collaborator_id(r.repo_id, compute_identity_id(b"carol"), _pending_at.isoformat()),
1297 repo_id=r.repo_id,
1298 identity_handle="carol",
1299 permission="read",
1300 accepted_at=None,
1301 )
1302 db_session.add(collab)
1303 await db_session.commit()
1304
1305 result = await execute_list_repos(actor="carol")
1306 assert result.ok is True
1307 slugs = [repo["slug"] for repo in result.data["repos"]]
1308 assert "pending-invisible" not in slugs
1309
1310 async def test_list_repos_excludes_deleted(self, db_session: AsyncSession) -> None:
1311 """Hard-deleted repos do not appear in the list."""
1312 from musehub.services.musehub_mcp_executor import execute_list_repos
1313
1314 r_live = await _repo(db_session, "live-repo", owner="dave")
1315 r_dead = await _repo(db_session, "deleted-repo", owner="dave")
1316 await db_session.delete(r_dead)
1317 await db_session.flush()
1318 await db_session.commit()
1319
1320 result = await execute_list_repos(actor="dave")
1321 assert result.ok is True
1322 slugs = [r["slug"] for r in result.data["repos"]]
1323 assert "live-repo" in slugs
1324 assert "deleted-repo" not in slugs
1325
1326 async def test_list_repos_total_excludes_deleted(self, db_session: AsyncSession) -> None:
1327 """total count does not include hard-deleted repos."""
1328 from musehub.services.musehub_mcp_executor import execute_list_repos
1329
1330 for i in range(3):
1331 await _repo(db_session, f"count-live-{i}", owner="eve")
1332 r_dead = await _repo(db_session, "count-dead", owner="eve")
1333 await db_session.delete(r_dead)
1334 await db_session.flush()
1335 await db_session.commit()
1336
1337 result = await execute_list_repos(actor="eve")
1338 assert result.ok is True
1339 assert result.data["total"] == 3
1340
1341 async def test_list_repos_pagination_stress(self, db_session: AsyncSession) -> None:
1342 """Paginating through 120 repos returns all without duplicates or misses."""
1343 from musehub.services.musehub_mcp_executor import execute_list_repos
1344
1345 for i in range(120):
1346 await _repo(db_session, f"stress-{i:03d}", owner="frank")
1347 await db_session.commit()
1348
1349 all_repos: list[dict] = []
1350 cursor: str | None = None
1351 pages = 0
1352
1353 while True:
1354 result = await execute_list_repos(actor="frank", limit=20, cursor=cursor)
1355 assert result.ok is True
1356 batch = result.data["repos"]
1357 all_repos.extend(batch)
1358 pages += 1
1359 cursor = result.data["next_cursor"]
1360 if cursor is None:
1361 break
1362
1363 assert len(all_repos) == 120, f"Expected 120, got {len(all_repos)}"
1364 ids = [r["repo_id"] for r in all_repos]
1365 assert len(ids) == len(set(ids)), "Duplicate repos found across pages"
1366 # With 120 repos and page size 20, the last full page sets a cursor; the
1367 # subsequent empty page terminates iteration. Accept 6 or 7 pages.
1368 assert pages <= 7, f"Unexpected page count: {pages}"
1369
1370 async def test_list_repos_cursor_is_stable_across_requests(self, db_session: AsyncSession) -> None:
1371 """Re-using the same cursor yields the same next page."""
1372 from musehub.services.musehub_mcp_executor import execute_list_repos
1373
1374 for i in range(30):
1375 await _repo(db_session, f"stable-{i:02d}", owner="grace")
1376 await db_session.commit()
1377
1378 page1 = await execute_list_repos(actor="grace", limit=10)
1379 cursor = page1.data["next_cursor"]
1380 assert cursor is not None
1381
1382 page2a = await execute_list_repos(actor="grace", limit=10, cursor=cursor)
1383 page2b = await execute_list_repos(actor="grace", limit=10, cursor=cursor)
1384
1385 assert page2a.data["repos"] == page2b.data["repos"]
1386
1387 async def test_list_repos_malformed_cursor_returns_first_page(self, db_session: AsyncSession) -> None:
1388 """A malformed cursor is silently ignored and returns from the first page."""
1389 from musehub.services.musehub_mcp_executor import execute_list_repos
1390
1391 for i in range(5):
1392 await _repo(db_session, f"cursor-test-{i}", owner="heidi")
1393 await db_session.commit()
1394
1395 result = await execute_list_repos(actor="heidi", cursor="not-a-timestamp")
1396 assert result.ok is True
1397 assert len(result.data["repos"]) == 5
1398
1399 async def test_list_repos_limit_clamped_at_100(self, db_session: AsyncSession) -> None:
1400 """limit values above 100 are clamped to 100."""
1401 from musehub.services.musehub_mcp_executor import execute_list_repos
1402
1403 for i in range(5):
1404 await _repo(db_session, f"clamp-{i}", owner="ivan")
1405 await db_session.commit()
1406
1407 result = await execute_list_repos(actor="ivan", limit=9999)
1408 assert result.ok is True
1409 # Only 5 repos exist; result set is smaller than the clamped limit
1410 assert len(result.data["repos"]) == 5
1411
File History 2 commits
sha256:f99af7b1a7f36c4d537d1c630d4b71fc39222b1255f82e930929e2fc89015e11 fix: relax browse_repo perf budget to 500ms — 200ms was too… Sonnet 4.6 19 days ago
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 20 days ago