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