gabriel / musehub public
test_mcp_read_tools.py python
951 lines 35.7 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 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_get_commit,
9 execute_compare, execute_whoami, execute_search_repos
10 and the helpers: _mime_for_path, _check_db_available, MusehubToolResult
11
12 Seven layers:
13
14 Layer 1 Unit:
15 - _mime_for_path: known MIDI extension, .webp custom, unknown → octet-stream, .py
16 - _check_db_available: factory=None → db_unavailable result
17 - MusehubToolResult: ok=True / ok=False shape invariants
18 - execute_get_analysis: invalid dimension returns immediately (no DB touch)
19 - execute_search: invalid mode returns immediately
20 - execute_whoami: user_id=None → authenticated=False immediately
21
22 Layer 2 Integration:
23 - execute_browse_repo: existing repo → ok=True with repo/branches/commits keys
24 - execute_browse_repo: unknown repo_id → ok=False, error_code=not_found
25 - execute_list_branches: existing repo → branch list returned
26 - execute_list_branches: unknown repo → not_found
27 - execute_list_commits: commits returned newest-first, branch filter, limit clamp
28 - execute_read_file: known object → ok=True, mime resolved
29 - execute_read_file: unknown object_id → not_found
30 - execute_read_file: unknown repo → not_found
31 - execute_get_commit: known commit → ok=True
32 - execute_get_commit: unknown commit → not_found
33 - execute_get_analysis: overview / commits / objects dimensions
34 - execute_search: path mode / commit mode case-insensitive
35 - execute_compare: ok=True with diff shape
36 - execute_whoami: with user_id → authenticated=True
37
38 Layer 3 E2E (HTTP tools/call):
39 - musehub_list_branches: isError=False, content is valid JSON
40 - musehub_list_branches unknown repo → isError=True
41 - musehub_list_commits with limit
42 - musehub_search invalid mode → isError=True
43 - musehub_get_commit not found → isError=True
44 - musehub_whoami anonymous → authenticated=False
45 - musehub_get_analysis invalid dimension → isError=True
46 - owner+slug transparent resolution
47
48 Layer 4 Stress:
49 - 50 commits → list_commits returns all 50
50 - 30 objects, search returns matching subset
51
52 Layer 5 Data Integrity:
53 - browse_repo response shape: all required top-level keys
54 - list_commits newest-first ordering
55 - read_file mime_type resolved per extension
56 - search path mode case-insensitive
57 - search commit mode case-insensitive
58 - get_analysis overview has all required fields
59
60 Layer 6 Security:
61 - execute_search_repos only returns public repos
62 - execute_whoami with None → authenticated=False (no data leakage)
63 - write tool via HTTP without auth → isError=True
64
65 Layer 7 Performance:
66 - 1000× _mime_for_path under 10 ms
67 - execute_browse_repo on populated repo under 200 ms
68 - execute_get_analysis overview under 200 ms
69 """
70 from __future__ import annotations
71
72 import json
73 import time
74 import uuid
75 from datetime import datetime, timezone
76
77 import pytest
78 import pytest_asyncio
79 from httpx import AsyncClient, ASGITransport
80 from sqlalchemy.ext.asyncio import AsyncSession
81
82 from musehub.db import musehub_models as db
83 from musehub.main import app
84 from musehub.muse_contracts.json_types import JSONObject
85 from musehub.services.musehub_mcp_executor import (
86 MusehubToolResult,
87 _check_db_available,
88 _mime_for_path,
89 execute_browse_repo,
90 execute_compare,
91 execute_get_analysis,
92 execute_get_commit,
93 execute_list_branches,
94 execute_list_commits,
95 execute_read_file,
96 execute_search,
97 execute_whoami,
98 )
99
100
101 # ── Fixtures ──────────────────────────────────────────────────────────────────
102
103
104 @pytest.fixture
105 def anyio_backend() -> str:
106 return "asyncio"
107
108
109 @pytest_asyncio.fixture
110 async def http_client(db_session: AsyncSession) -> AsyncClient:
111 async with AsyncClient(
112 transport=ASGITransport(app=app),
113 base_url="http://localhost",
114 ) as c:
115 yield c
116
117
118 # ── Helpers ───────────────────────────────────────────────────────────────────
119
120
121 def _uid() -> str:
122 return str(uuid.uuid4())
123
124
125 async def _repo(
126 session: AsyncSession,
127 slug: str,
128 visibility: str = "public",
129 owner: str = "alice",
130 ) -> db.MusehubRepo:
131 repo = db.MusehubRepo(
132 name=slug,
133 owner=owner,
134 slug=slug,
135 visibility=visibility,
136 owner_user_id="uid-alice",
137 )
138 session.add(repo)
139 await session.flush()
140 await session.refresh(repo)
141 return repo
142
143
144 async def _commit(
145 session: AsyncSession,
146 repo_id: str,
147 branch: str = "main",
148 message: str = "add track",
149 author: str = "alice",
150 ts: datetime | None = None,
151 ) -> db.MusehubCommit:
152 c = db.MusehubCommit(
153 commit_id=_uid()[:16],
154 repo_id=repo_id,
155 branch=branch,
156 parent_ids=[],
157 message=message,
158 author=author,
159 timestamp=ts or datetime.now(tz=timezone.utc),
160 )
161 session.add(c)
162 await session.flush()
163 return c
164
165
166 async def _branch(
167 session: AsyncSession,
168 repo_id: str,
169 name: str,
170 head_commit_id: str,
171 ) -> db.MusehubBranch:
172 b = db.MusehubBranch(
173 repo_id=repo_id,
174 name=name,
175 head_commit_id=head_commit_id,
176 )
177 session.add(b)
178 await session.flush()
179 return b
180
181
182 async def _object(
183 session: AsyncSession,
184 repo_id: str,
185 path: str,
186 size_bytes: int = 1024,
187 ) -> db.MusehubObject:
188 obj = db.MusehubObject(
189 object_id=f"sha256:{_uid()[:32]}",
190 repo_id=repo_id,
191 path=path,
192 size_bytes=size_bytes,
193 disk_path=f"/tmp/{_uid()}.bin",
194 )
195 session.add(obj)
196 await session.flush()
197 return obj
198
199
200 def _tools_call(name: str, arguments: JSONObject) -> JSONObject:
201 return {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": arguments}}
202
203
204 def _unwrap_tool_text(text: str) -> str:
205 """Strip <musehub_tool_result> wrapper tags added by the dispatcher."""
206 text = text.strip()
207 if text.startswith("<musehub_tool_result>"):
208 text = text[len("<musehub_tool_result>"):].strip()
209 if text.endswith("</musehub_tool_result>"):
210 text = text[: -len("</musehub_tool_result>")].strip()
211 return text
212
213
214 async def _init_session(http_client: AsyncClient) -> str:
215 """POST initialize and return the session_id."""
216 resp = await http_client.post(
217 "/mcp",
218 json={
219 "jsonrpc": "2.0", "id": 1, "method": "initialize",
220 "params": {
221 "protocolVersion": "2025-11-25",
222 "clientInfo": {"name": "test", "version": "1.0"},
223 "capabilities": {},
224 },
225 },
226 headers={"Content-Type": "application/json"},
227 )
228 return resp.headers["mcp-session-id"]
229
230
231 # ── Layer 1 — Unit ────────────────────────────────────────────────────────────
232
233
234 class TestUnitMimeForPath:
235 def test_midi_extension(self) -> None:
236 mime = _mime_for_path("tracks/song.mid")
237 assert mime == "audio/midi"
238
239 def test_webp_custom_extension(self) -> None:
240 assert _mime_for_path("image.webp") == "image/webp"
241
242 def test_unknown_extension_returns_octet_stream(self) -> None:
243 assert _mime_for_path("artifact.xyz123") == "application/octet-stream"
244
245 def test_python_extension(self) -> None:
246 assert "python" in _mime_for_path("script.py").lower()
247
248 def test_no_extension_returns_octet_stream(self) -> None:
249 assert _mime_for_path("noextension") == "application/octet-stream"
250
251 def test_case_insensitive_extension(self) -> None:
252 upper = _mime_for_path("TRACK.WEBP")
253 lower = _mime_for_path("track.webp")
254 assert upper == lower
255
256
257 class TestUnitCheckDbAvailable:
258 def test_factory_none_returns_error(self) -> None:
259 from musehub.db import database
260 original = database._async_session_factory
261 try:
262 setattr(database, '_async_session_factory', None)
263 result = _check_db_available()
264 assert result is not None
265 assert result.ok is False
266 assert result.error_code == "db_unavailable"
267 assert result.error_message is not None
268 finally:
269 database._async_session_factory = original
270
271 def test_factory_set_returns_none(self, db_session: AsyncSession) -> None:
272 """With db_session fixture active, factory is set — check returns None."""
273 result = _check_db_available()
274 assert result is None
275
276
277 class TestUnitMusehubToolResult:
278 def test_ok_true_shape(self) -> None:
279 r = MusehubToolResult(ok=True, data={"repo_id": "abc"})
280 assert r.ok is True
281 assert r.data == {"repo_id": "abc"}
282 assert r.error_code is None
283 assert r.error_message is None
284
285 def test_ok_false_shape(self) -> None:
286 r = MusehubToolResult(
287 ok=False,
288 error_code="not_found",
289 error_message="Repo not found.",
290 )
291 assert r.ok is False
292 assert r.error_code == "not_found"
293 assert "not found" in r.error_message.lower()
294 assert r.data == {}
295
296
297 class TestUnitValidationWithoutDB:
298 @pytest.mark.anyio
299 async def test_get_analysis_invalid_dimension(self, db_session: AsyncSession) -> None:
300 result = await execute_get_analysis("any-repo-id", dimension="music")
301 assert result.ok is False
302 assert result.error_code == "invalid_args"
303 assert "music" in (result.error_message or "")
304
305 @pytest.mark.anyio
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 @pytest.mark.anyio
313 async def test_whoami_anonymous(self) -> None:
314 """execute_whoami with None returns authenticated=False without hitting DB."""
315 result = await execute_whoami(None)
316 assert result.ok is True
317 assert result.data["authenticated"] is False
318 assert result.data["user_id"] is None
319
320
321 # ── Layer 2 — Integration ─────────────────────────────────────────────────────
322
323
324 class TestIntegrationBrowseRepo:
325 @pytest.mark.anyio
326 async def test_existing_repo_returns_ok(self, db_session: AsyncSession) -> None:
327 r = await _repo(db_session, "browse-ok")
328 c = await _commit(db_session, r.repo_id)
329 await _branch(db_session, r.repo_id, "main", c.commit_id)
330 await db_session.commit()
331
332 result = await execute_browse_repo(r.repo_id)
333
334 assert result.ok is True
335 assert "repo" in result.data
336 assert "branches" in result.data
337 assert "recent_commits" in result.data
338 assert result.data["branch_count"] == 1
339
340 @pytest.mark.anyio
341 async def test_unknown_repo_returns_not_found(self, db_session: AsyncSession) -> None:
342 result = await execute_browse_repo("nonexistent-repo-id")
343 assert result.ok is False
344 assert result.error_code == "repo_not_found"
345
346
347 class TestIntegrationListBranches:
348 @pytest.mark.anyio
349 async def test_returns_branches(self, db_session: AsyncSession) -> None:
350 r = await _repo(db_session, "lb-ok")
351 c = await _commit(db_session, r.repo_id)
352 await _branch(db_session, r.repo_id, "main", c.commit_id)
353 await _branch(db_session, r.repo_id, "dev", c.commit_id)
354 await db_session.commit()
355
356 result = await execute_list_branches(r.repo_id)
357
358 assert result.ok is True
359 assert result.data["branch_count"] == 2
360 names = [b["name"] for b in result.data["branches"]]
361 assert "main" in names
362 assert "dev" in names
363
364 @pytest.mark.anyio
365 async def test_unknown_repo_returns_not_found(self, db_session: AsyncSession) -> None:
366 result = await execute_list_branches("ghost-repo")
367 assert result.ok is False
368 assert result.error_code == "repo_not_found"
369
370
371 class TestIntegrationListCommits:
372 @pytest.mark.anyio
373 async def test_returns_commits(self, db_session: AsyncSession) -> None:
374 r = await _repo(db_session, "lc-ok")
375 for i in range(5):
376 await _commit(db_session, r.repo_id, message=f"commit {i}")
377 await db_session.commit()
378
379 result = await execute_list_commits(r.repo_id, limit=10)
380
381 assert result.ok is True
382 assert result.data["returned"] == 5
383
384 @pytest.mark.anyio
385 async def test_branch_filter(self, db_session: AsyncSession) -> None:
386 r = await _repo(db_session, "lc-branch")
387 await _commit(db_session, r.repo_id, branch="main", message="on main")
388 await _commit(db_session, r.repo_id, branch="dev", message="on dev")
389 await db_session.commit()
390
391 result = await execute_list_commits(r.repo_id, branch="main", limit=10)
392
393 assert result.ok is True
394 commits = result.data["commits"]
395 assert all(c["branch"] == "main" for c in commits)
396
397 @pytest.mark.anyio
398 async def test_limit_clamped_high(self, db_session: AsyncSession) -> None:
399 """Limit values over 100 are clamped to 100."""
400 r = await _repo(db_session, "lc-clamp-hi")
401 for _ in range(5):
402 await _commit(db_session, r.repo_id)
403 await db_session.commit()
404
405 # limit=200 should clamp to 100 but still return all 5
406 result = await execute_list_commits(r.repo_id, limit=200)
407 assert result.ok is True
408 assert result.data["returned"] == 5
409
410 @pytest.mark.anyio
411 async def test_limit_clamped_low(self, db_session: AsyncSession) -> None:
412 """Limit values below 1 are clamped to 1."""
413 r = await _repo(db_session, "lc-clamp-lo")
414 for _ in range(5):
415 await _commit(db_session, r.repo_id)
416 await db_session.commit()
417
418 result = await execute_list_commits(r.repo_id, limit=0)
419 assert result.ok is True
420 assert result.data["returned"] == 1
421
422 @pytest.mark.anyio
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 @pytest.mark.anyio
431 async def test_known_object_returns_metadata(self, db_session: AsyncSession) -> None:
432 r = await _repo(db_session, "rf-ok")
433 obj = await _object(db_session, r.repo_id, "tracks/bass.mid", size_bytes=4096)
434 await db_session.commit()
435
436 result = await execute_read_file(r.repo_id, obj.object_id)
437
438 assert result.ok is True
439 assert result.data["object_id"] == obj.object_id
440 assert result.data["path"] == "tracks/bass.mid"
441 assert result.data["size_bytes"] == 4096
442 assert "midi" in result.data["mime_type"].lower()
443
444 @pytest.mark.anyio
445 async def test_unknown_object_returns_not_found(self, db_session: AsyncSession) -> None:
446 r = await _repo(db_session, "rf-no-obj")
447 await db_session.commit()
448 result = await execute_read_file(r.repo_id, "sha256:deadbeef")
449 assert result.ok is False
450 assert result.error_code == "file_not_found"
451
452 @pytest.mark.anyio
453 async def test_unknown_repo_returns_not_found(self, db_session: AsyncSession) -> None:
454 result = await execute_read_file("ghost-repo", "sha256:anything")
455 assert result.ok is False
456 assert result.error_code == "repo_not_found"
457
458
459 class TestIntegrationGetCommit:
460 @pytest.mark.anyio
461 async def test_known_commit_returns_data(self, db_session: AsyncSession) -> None:
462 r = await _repo(db_session, "gc-ok")
463 c = await _commit(db_session, r.repo_id, message="feature: harmony")
464 await db_session.commit()
465
466 result = await execute_get_commit(r.repo_id, c.commit_id)
467
468 assert result.ok is True
469 assert result.data["commit_id"] == c.commit_id
470 assert result.data["message"] == "feature: harmony"
471 assert result.data["author"] == "alice"
472
473 @pytest.mark.anyio
474 async def test_unknown_commit_returns_not_found(self, db_session: AsyncSession) -> None:
475 r = await _repo(db_session, "gc-miss")
476 await db_session.commit()
477 result = await execute_get_commit(r.repo_id, "nonexistent-commit-id")
478 assert result.ok is False
479 assert result.error_code == "commit_not_found"
480
481
482 class TestIntegrationGetAnalysis:
483 @pytest.mark.anyio
484 async def test_overview_dimension(self, db_session: AsyncSession) -> None:
485 r = await _repo(db_session, "ga-overview")
486 c = await _commit(db_session, r.repo_id)
487 await _branch(db_session, r.repo_id, "main", c.commit_id)
488 await _object(db_session, r.repo_id, "track.mid")
489 await db_session.commit()
490
491 result = await execute_get_analysis(r.repo_id, dimension="overview")
492
493 assert result.ok is True
494 d = result.data
495 assert d["dimension"] == "overview"
496 assert d["branch_count"] == 1
497 assert d["commit_count"] >= 1
498 assert d["object_count"] == 1
499
500 @pytest.mark.anyio
501 async def test_commits_dimension(self, db_session: AsyncSession) -> None:
502 r = await _repo(db_session, "ga-commits")
503 await _commit(db_session, r.repo_id, branch="main", author="alice")
504 await _commit(db_session, r.repo_id, branch="dev", author="bob")
505 await db_session.commit()
506
507 result = await execute_get_analysis(r.repo_id, dimension="commits")
508
509 assert result.ok is True
510 d = result.data
511 assert d["dimension"] == "commits"
512 assert "by_branch" in d
513 assert "by_author" in d
514 assert d["by_author"].get("alice", 0) >= 1
515 assert d["by_author"].get("bob", 0) >= 1
516
517 @pytest.mark.anyio
518 async def test_objects_dimension(self, db_session: AsyncSession) -> None:
519 r = await _repo(db_session, "ga-objects")
520 await _object(db_session, r.repo_id, "a.mid", size_bytes=100)
521 await _object(db_session, r.repo_id, "b.mid", size_bytes=200)
522 await db_session.commit()
523
524 result = await execute_get_analysis(r.repo_id, dimension="objects")
525
526 assert result.ok is True
527 d = result.data
528 assert d["dimension"] == "objects"
529 assert d["total_objects"] == 2
530 assert d["total_size_bytes"] == 300
531
532
533 class TestIntegrationSearch:
534 @pytest.mark.anyio
535 async def test_path_mode_returns_matching_objects(self, db_session: AsyncSession) -> None:
536 r = await _repo(db_session, "s-path")
537 await _object(db_session, r.repo_id, "tracks/jazz_bass.mid")
538 await _object(db_session, r.repo_id, "tracks/treble.mid")
539 await db_session.commit()
540
541 result = await execute_search(r.repo_id, "jazz", mode="path")
542
543 assert result.ok is True
544 assert result.data["result_count"] == 1
545 assert result.data["results"][0]["path"] == "tracks/jazz_bass.mid"
546
547 @pytest.mark.anyio
548 async def test_path_mode_case_insensitive(self, db_session: AsyncSession) -> None:
549 r = await _repo(db_session, "s-ci-path")
550 await _object(db_session, r.repo_id, "JAZZ_TRACK.mid")
551 await db_session.commit()
552
553 result = await execute_search(r.repo_id, "jazz", mode="path")
554 assert result.ok is True
555 assert result.data["result_count"] == 1
556
557 @pytest.mark.anyio
558 async def test_commit_mode_returns_matching_commits(self, db_session: AsyncSession) -> None:
559 r = await _repo(db_session, "s-commit")
560 await _commit(db_session, r.repo_id, message="add bass groove")
561 await _commit(db_session, r.repo_id, message="fix tempo sync")
562 await db_session.commit()
563
564 result = await execute_search(r.repo_id, "bass", mode="commit")
565
566 assert result.ok is True
567 assert result.data["result_count"] == 1
568 assert "bass" in result.data["results"][0]["message"].lower()
569
570 @pytest.mark.anyio
571 async def test_commit_mode_case_insensitive(self, db_session: AsyncSession) -> None:
572 r = await _repo(db_session, "s-ci-commit")
573 await _commit(db_session, r.repo_id, message="Add BASS line")
574 await db_session.commit()
575
576 result = await execute_search(r.repo_id, "bass", mode="commit")
577 assert result.ok is True
578 assert result.data["result_count"] == 1
579
580
581 class TestIntegrationCompare:
582 @pytest.mark.anyio
583 async def test_compare_returns_diff_shape(self, db_session: AsyncSession) -> None:
584 r = await _repo(db_session, "compare-ok")
585 ca = await _commit(db_session, r.repo_id, branch="main")
586 cb = await _commit(db_session, r.repo_id, branch="dev")
587 await db_session.commit()
588
589 result = await execute_compare(r.repo_id, base_ref="main", head_ref="dev")
590
591 assert result.ok is True
592 assert result.data["base_ref"] == "main"
593 assert result.data["head_ref"] == "dev"
594 assert "base_commit_id" in result.data
595 assert "head_commit_id" in result.data
596
597
598 class TestIntegrationWhoami:
599 @pytest.mark.anyio
600 async def test_authenticated_user_returns_data(self, db_session: AsyncSession) -> None:
601 result = await execute_whoami("uid-test-user")
602 assert result.ok is True
603 assert result.data["authenticated"] is True
604 assert result.data["user_id"] == "uid-test-user"
605
606
607 # ── Layer 3 — End-to-End ──────────────────────────────────────────────────────
608
609
610 class TestE2EReadTools:
611 @pytest.mark.anyio
612 async def test_list_branches_returns_valid_json_content(
613 self, http_client: AsyncClient, db_session: AsyncSession
614 ) -> None:
615 r = await _repo(db_session, "e2e-lb")
616 c = await _commit(db_session, r.repo_id)
617 await _branch(db_session, r.repo_id, "main", c.commit_id)
618 await db_session.commit()
619
620 resp = await http_client.post(
621 "/mcp",
622 json=_tools_call("musehub_list_branches", {"repo_id": r.repo_id}),
623 headers={"Content-Type": "application/json"},
624 )
625 assert resp.status_code == 200
626 data = resp.json()
627 assert data["result"]["isError"] is False
628 text = _unwrap_tool_text(data["result"]["content"][0]["text"])
629 payload = json.loads(text)
630 assert "branches" in payload
631
632 @pytest.mark.anyio
633 async def test_list_branches_unknown_repo_returns_iserror(
634 self, http_client: AsyncClient, db_session: AsyncSession
635 ) -> None:
636 resp = await http_client.post(
637 "/mcp",
638 json=_tools_call("musehub_list_branches", {"repo_id": "ghost-e2e"}),
639 headers={"Content-Type": "application/json"},
640 )
641 assert resp.status_code == 200
642 data = resp.json()
643 assert data["result"]["isError"] is True
644 error = json.loads(data["result"]["content"][0]["text"])
645 assert error["error_code"] == "repo_not_found"
646
647 @pytest.mark.anyio
648 async def test_list_commits_with_limit(
649 self, http_client: AsyncClient, db_session: AsyncSession
650 ) -> None:
651 r = await _repo(db_session, "e2e-lc")
652 for _ in range(10):
653 await _commit(db_session, r.repo_id)
654 await db_session.commit()
655
656 resp = await http_client.post(
657 "/mcp",
658 json=_tools_call("musehub_list_commits", {"repo_id": r.repo_id, "limit": 5}),
659 headers={"Content-Type": "application/json"},
660 )
661 assert resp.status_code == 200
662 result = resp.json()["result"]
663 assert result["isError"] is False
664 payload = json.loads(_unwrap_tool_text(result["content"][0]["text"]))
665 assert payload["returned"] <= 5
666
667 @pytest.mark.anyio
668 async def test_search_invalid_mode_returns_iserror(
669 self, http_client: AsyncClient, db_session: AsyncSession
670 ) -> None:
671 r = await _repo(db_session, "e2e-s-mode")
672 await db_session.commit()
673
674 resp = await http_client.post(
675 "/mcp",
676 json=_tools_call("musehub_search", {"repo_id": r.repo_id, "query": "x", "mode": "invalid"}),
677 headers={"Content-Type": "application/json"},
678 )
679 assert resp.status_code == 200
680 assert resp.json()["result"]["isError"] is True
681
682 @pytest.mark.anyio
683 async def test_get_commit_not_found_returns_iserror(
684 self, http_client: AsyncClient, db_session: AsyncSession
685 ) -> None:
686 r = await _repo(db_session, "e2e-gc")
687 await db_session.commit()
688
689 resp = await http_client.post(
690 "/mcp",
691 json=_tools_call("musehub_get_commit", {"repo_id": r.repo_id, "commit_id": "ghost-commit"}),
692 headers={"Content-Type": "application/json"},
693 )
694 assert resp.status_code == 200
695 assert resp.json()["result"]["isError"] is True
696
697 @pytest.mark.anyio
698 async def test_whoami_anonymous_returns_not_authenticated(
699 self, http_client: AsyncClient, db_session: AsyncSession
700 ) -> None:
701 resp = await http_client.post(
702 "/mcp",
703 json=_tools_call("musehub_whoami", {}),
704 headers={"Content-Type": "application/json"},
705 )
706 assert resp.status_code == 200
707 result = resp.json()["result"]
708 assert result["isError"] is False
709 payload = json.loads(_unwrap_tool_text(result["content"][0]["text"]))
710 assert payload["authenticated"] is False
711
712 @pytest.mark.anyio
713 async def test_get_analysis_invalid_dimension_returns_iserror(
714 self, http_client: AsyncClient, db_session: AsyncSession
715 ) -> None:
716 r = await _repo(db_session, "e2e-ga-bad")
717 await db_session.commit()
718
719 resp = await http_client.post(
720 "/mcp",
721 json=_tools_call("musehub_get_analysis", {"repo_id": r.repo_id, "dimension": "music"}),
722 headers={"Content-Type": "application/json"},
723 )
724 assert resp.status_code == 200
725 assert resp.json()["result"]["isError"] is True
726
727 @pytest.mark.anyio
728 async def test_unknown_tool_returns_iserror(
729 self, http_client: AsyncClient, db_session: AsyncSession
730 ) -> None:
731 resp = await http_client.post(
732 "/mcp",
733 json=_tools_call("musehub_no_such_tool", {}),
734 headers={"Content-Type": "application/json"},
735 )
736 assert resp.status_code == 200
737 assert resp.json()["result"]["isError"] is True
738
739
740 # ── Layer 4 — Stress ──────────────────────────────────────────────────────────
741
742
743 class TestStressReadTools:
744 @pytest.mark.anyio
745 async def test_50_commits_all_returned(self, db_session: AsyncSession) -> None:
746 r = await _repo(db_session, "stress-commits")
747 for i in range(50):
748 await _commit(db_session, r.repo_id, message=f"commit {i}")
749 await db_session.commit()
750
751 result = await execute_list_commits(r.repo_id, limit=100)
752 assert result.ok is True
753 assert result.data["returned"] == 50
754
755 @pytest.mark.anyio
756 async def test_30_objects_search_returns_subset(self, db_session: AsyncSession) -> None:
757 r = await _repo(db_session, "stress-search")
758 # 20 matching objects + 10 non-matching
759 for i in range(20):
760 await _object(db_session, r.repo_id, f"jazz/track_{i}.mid")
761 for i in range(10):
762 await _object(db_session, r.repo_id, f"blues/track_{i}.mid")
763 await db_session.commit()
764
765 result = await execute_search(r.repo_id, "jazz", mode="path")
766 assert result.ok is True
767 assert result.data["result_count"] == 20
768
769
770 # ── Layer 5 — Data Integrity ──────────────────────────────────────────────────
771
772
773 class TestDataIntegrityBrowseRepo:
774 @pytest.mark.anyio
775 async def test_response_has_all_required_keys(self, db_session: AsyncSession) -> None:
776 r = await _repo(db_session, "di-browse")
777 c = await _commit(db_session, r.repo_id)
778 await _branch(db_session, r.repo_id, "main", c.commit_id)
779 await db_session.commit()
780
781 result = await execute_browse_repo(r.repo_id)
782 assert result.ok is True
783 for key in ("repo", "branches", "recent_commits", "total_commits", "branch_count"):
784 assert key in result.data, f"Missing key: {key}"
785
786 @pytest.mark.anyio
787 async def test_repo_sub_dict_has_required_fields(self, db_session: AsyncSession) -> None:
788 r = await _repo(db_session, "di-browse-repo")
789 c = await _commit(db_session, r.repo_id)
790 await _branch(db_session, r.repo_id, "main", c.commit_id)
791 await db_session.commit()
792
793 result = await execute_browse_repo(r.repo_id)
794 repo_data = result.data["repo"]
795 for field in ("repo_id", "name", "visibility", "owner_user_id", "created_at"):
796 assert field in repo_data, f"Missing repo field: {field}"
797
798
799 class TestDataIntegrityCommitOrdering:
800 @pytest.mark.anyio
801 async def test_commits_newest_first(self, db_session: AsyncSession) -> None:
802 from datetime import timedelta
803
804 r = await _repo(db_session, "di-order")
805 base = datetime.now(tz=timezone.utc)
806 for i in range(5):
807 await _commit(
808 db_session, r.repo_id,
809 message=f"commit {i}",
810 ts=base + timedelta(seconds=i),
811 )
812 await db_session.commit()
813
814 result = await execute_list_commits(r.repo_id, limit=10)
815 commits = result.data["commits"]
816 timestamps = [c["timestamp"] for c in commits]
817 assert timestamps == sorted(timestamps, reverse=True)
818
819
820 class TestDataIntegrityReadFileMime:
821 @pytest.mark.anyio
822 async def test_webp_mime_resolved(self, db_session: AsyncSession) -> None:
823 r = await _repo(db_session, "di-mime-webp")
824 obj = await _object(db_session, r.repo_id, "roll.webp")
825 await db_session.commit()
826 result = await execute_read_file(r.repo_id, obj.object_id)
827 assert result.ok is True
828 assert result.data["mime_type"] == "image/webp"
829
830 @pytest.mark.anyio
831 async def test_midi_mime_resolved(self, db_session: AsyncSession) -> None:
832 r = await _repo(db_session, "di-mime-mid")
833 obj = await _object(db_session, r.repo_id, "track.mid")
834 await db_session.commit()
835 result = await execute_read_file(r.repo_id, obj.object_id)
836 assert result.ok is True
837 assert "midi" in result.data["mime_type"].lower()
838
839
840 class TestDataIntegrityGetAnalysisOverview:
841 @pytest.mark.anyio
842 async def test_overview_has_all_required_fields(self, db_session: AsyncSession) -> None:
843 r = await _repo(db_session, "di-ga-overview")
844 c = await _commit(db_session, r.repo_id)
845 await _branch(db_session, r.repo_id, "main", c.commit_id)
846 await db_session.commit()
847
848 result = await execute_get_analysis(r.repo_id, dimension="overview")
849 assert result.ok is True
850 for field in ("repo_id", "dimension", "repo_name", "visibility",
851 "branch_count", "commit_count", "object_count"):
852 assert field in result.data, f"Missing field: {field}"
853
854
855 # ── Layer 6 — Security ────────────────────────────────────────────────────────
856
857
858 class TestSecurityReadTools:
859 @pytest.mark.anyio
860 async def test_whoami_anonymous_returns_no_user_data(self) -> None:
861 """Anonymous whoami must not leak user info."""
862 result = await execute_whoami(None)
863 assert result.ok is True
864 assert result.data["authenticated"] is False
865 assert result.data["user_id"] is None
866 # Must not contain any other fields that could leak data.
867 assert "repo_count" not in result.data
868
869 @pytest.mark.anyio
870 async def test_search_repos_only_returns_public_repos(
871 self, db_session: AsyncSession
872 ) -> None:
873 """execute_search_repos must never return private repos."""
874 await _repo(db_session, "sec-public", visibility="public")
875 await _repo(db_session, "sec-private", visibility="private")
876 await db_session.commit()
877
878 from musehub.services.musehub_mcp_executor import execute_search_repos
879 result = await execute_search_repos(query="sec", limit=50)
880 assert result.ok is True
881 names = [r["name"] for r in result.data["repos"]]
882 assert "sec-private" not in names
883
884 @pytest.mark.anyio
885 async def test_write_tool_via_mcp_requires_auth(
886 self, http_client: AsyncClient, db_session: AsyncSession
887 ) -> None:
888 """musehub_create_repo (write tool) called without auth returns 401."""
889 resp = await http_client.post(
890 "/mcp",
891 json=_tools_call("musehub_create_repo", {"name": "should-fail", "owner": "alice"}),
892 headers={"Content-Type": "application/json"},
893 )
894 assert resp.status_code == 401
895
896 @pytest.mark.anyio
897 async def test_read_file_unknown_repo_does_not_crash(self, db_session: AsyncSession) -> None:
898 """Unknown repo must return not_found, never raise an exception."""
899 result = await execute_read_file("completely-made-up-id", "sha256:x")
900 assert not result.ok
901 assert result.error_code == "repo_not_found"
902
903
904 # ── Layer 7 — Performance ─────────────────────────────────────────────────────
905
906
907 class TestPerformanceMimeResolution:
908 def test_1000_mime_resolutions_under_10ms(self) -> None:
909 paths = [
910 "track.mid", "cover.webp", "audio.mp3", "script.py",
911 "unknown.xyz", "noext", "deep/path/to/file.mid",
912 ]
913 start = time.perf_counter()
914 for i in range(1000):
915 _mime_for_path(paths[i % len(paths)])
916 elapsed_ms = (time.perf_counter() - start) * 1000
917 assert elapsed_ms < 10, f"1000× _mime_for_path took {elapsed_ms:.1f} ms"
918
919
920 class TestPerformanceExecutors:
921 @pytest.mark.anyio
922 async def test_browse_repo_under_200ms(self, db_session: AsyncSession) -> None:
923 r = await _repo(db_session, "perf-browse")
924 for _ in range(10):
925 c = await _commit(db_session, r.repo_id)
926 await _branch(db_session, r.repo_id, "main", c.commit_id)
927 for _ in range(5):
928 await _object(db_session, r.repo_id, f"track_{_}.mid")
929 await db_session.commit()
930
931 start = time.perf_counter()
932 result = await execute_browse_repo(r.repo_id)
933 elapsed_ms = (time.perf_counter() - start) * 1000
934
935 assert result.ok is True
936 assert elapsed_ms < 200, f"execute_browse_repo took {elapsed_ms:.1f} ms"
937
938 @pytest.mark.anyio
939 async def test_get_analysis_overview_under_200ms(self, db_session: AsyncSession) -> None:
940 r = await _repo(db_session, "perf-analysis")
941 for _ in range(20):
942 c = await _commit(db_session, r.repo_id)
943 await _branch(db_session, r.repo_id, "main", c.commit_id)
944 await db_session.commit()
945
946 start = time.perf_counter()
947 result = await execute_get_analysis(r.repo_id, dimension="overview")
948 elapsed_ms = (time.perf_counter() - start) * 1000
949
950 assert result.ok is True
951 assert elapsed_ms < 200, f"execute_get_analysis(overview) took {elapsed_ms:.1f} ms"
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago