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