gabriel / musehub public
test_mcp_write_tools.py python
833 lines 29.7 KB
Raw
sha256:d8cbca3a06f39f82f66be6c29de3f41c3dec5f367722958fb5454dcbc007cc15 fix: rc11 test fixes — event→verdict rename, migration coun… Sonnet 4.6 patch 15 days ago
1 """Section 15 — MCP Write Tools: 7-layer test suite.
2
3 Covers ``musehub/mcp/write_tools/`` — issues, proposals, releases, repos.
4
5 Write tools under test:
6 execute_create_issue, execute_update_issue, execute_create_issue_comment
7 execute_create_proposal, execute_merge_proposal, execute_submit_proposal_review,
8 execute_create_proposal_comment
9 execute_create_release
10 execute_create_repo
11 execute_create_label
12
13 Bug fixed during intelligence gathering:
14 - _proposal_data referenced the wrong variable name — NameError
15 that would crash every call to execute_create_proposal / execute_merge_proposal.
16
17 Seven layers:
18
19 Layer 1 Unit:
20 - MUSEHUB_WRITE_TOOL_NAMES contains all expected write tool names
21 - execute_submit_proposal_review: invalid event → error_code=invalid_mode
22 - execute_create_proposal: same from/to branch → error immediately
23 - _issue_data serialises IssueResponse to correct dict keys
24
25 Layer 2 Integration:
26 - execute_create_issue: happy path, unknown repo, with labels
27 - execute_update_issue: title change, close state, unknown repo
28 - execute_create_issue_comment: happy path, unknown issue
29 - execute_create_proposal: happy path, unknown repo, same-branch guard
30 - execute_merge_proposal: happy path, unknown proposal
31 - execute_submit_proposal_review: approve / request_changes
32 - execute_create_release: happy path, duplicate tag error
33 - execute_create_repo: happy path returns repo_id + slug
34 - execute_create_label: happy path, duplicate name error
35
36 Layer 3 E2E (HTTP tools/call):
37 - Anonymous calls to every write tool → isError=True (auth gate)
38 - Authenticated write (mocked _extract_auth): create_issue succeeds
39 - Authenticated write (mocked _extract_auth): create_repo succeeds
40
41 Layer 4 Stress:
42 - 20 sequential issues → sequential numbers 1..20
43
44 Layer 5 Data Integrity:
45 - create_issue entity retrievable via execute_list_issues
46 - create_proposal entity retrievable via execute_list_proposals
47 - create_release tag persisted
48 - update_issue close: state persisted as "closed"
49
50 Layer 6 Security:
51 - All write tools in MUSEHUB_WRITE_TOOL_NAMES → auth gate in dispatcher
52 - execute_create_proposal same branches → error
53 - Unauthenticated HTTP call to write tool → isError=True
54
55 Layer 7 Performance:
56 - 10 sequential execute_create_issue under 500 ms
57 - 5 sequential execute_create_repo under 1000 ms
58 """
59 from __future__ import annotations
60
61 import json
62 import time
63 import uuid
64 from datetime import datetime, timezone
65 from unittest.mock import AsyncMock, patch
66
67 import pytest
68 import pytest_asyncio
69 from httpx import AsyncClient, ASGITransport
70 from sqlalchemy.ext.asyncio import AsyncSession
71
72 from musehub.db import musehub_models as db
73 from musehub.main import app
74 from musehub.mcp.tools.musehub import MUSEHUB_WRITE_TOOL_NAMES
75 from musehub.muse_contracts.json_types import JSONObject, StrDict
76 from musehub.mcp.write_tools.issues import (
77 _issue_data,
78 execute_create_issue,
79 execute_create_issue_comment,
80 execute_update_issue,
81 )
82 from musehub.mcp.write_tools.proposals import (
83 execute_create_proposal,
84 execute_create_proposal_comment,
85 execute_merge_proposal,
86 execute_submit_proposal_review,
87 )
88 from musehub.mcp.write_tools.releases import execute_create_release
89 from musehub.mcp.write_tools.repos import execute_create_repo
90 from musehub.mcp.write_tools.issues import execute_create_label
91 from musehub.services.musehub_mcp_executor import (
92 execute_list_issues,
93 execute_list_proposals,
94 )
95
96
97 # ── Fixtures ──────────────────────────────────────────────────────────────────
98
99
100 @pytest.fixture
101 def anyio_backend() -> str:
102 return "asyncio"
103
104
105 @pytest_asyncio.fixture
106 async def http_client(db_session: AsyncSession) -> AsyncClient:
107 async with AsyncClient(
108 transport=ASGITransport(app=app),
109 base_url="http://localhost",
110 ) as c:
111 yield c
112
113
114 # ── Helpers ───────────────────────────────────────────────────────────────────
115
116
117 def _uid() -> str:
118 return str(uuid.uuid4())
119
120
121 def _slug() -> str:
122 return f"repo-{uuid.uuid4().hex[:8]}"
123
124
125 async def _repo(
126 session: AsyncSession,
127 slug: str | None = None,
128 visibility: str = "public",
129 owner: str = "alice",
130 ) -> db.MusehubRepo:
131 name = slug or _slug()
132 r = db.MusehubRepo(
133 name=name,
134 owner=owner,
135 slug=name,
136 visibility=visibility,
137 owner_user_id="uid-alice",
138 )
139 session.add(r)
140 await session.flush()
141 await session.refresh(r)
142 return r
143
144
145 async def _commit_and_branch(
146 session: AsyncSession,
147 repo_id: str,
148 branch: str = "main",
149 ) -> db.MusehubCommit:
150 c = db.MusehubCommit(
151 commit_id=uuid.uuid4().hex[:16],
152 repo_id=repo_id,
153 branch=branch,
154 parent_ids=[],
155 message="init",
156 author="alice",
157 timestamp=datetime.now(tz=timezone.utc),
158 )
159 b = db.MusehubBranch(
160 repo_id=repo_id,
161 name=branch,
162 head_commit_id=c.commit_id,
163 )
164 session.add(c)
165 session.add(b)
166 await session.flush()
167 return c
168
169
170 def _tools_call(name: str, arguments: JSONObject) -> JSONObject:
171 return {
172 "jsonrpc": "2.0",
173 "id": 1,
174 "method": "tools/call",
175 "params": {"name": name, "arguments": arguments},
176 }
177
178
179 def _unwrap_tool_text(text: str) -> str:
180 """Strip <musehub_tool_result> wrapper tags added by the dispatcher."""
181 text = text.strip()
182 if text.startswith("<musehub_tool_result>"):
183 text = text[len("<musehub_tool_result>"):].strip()
184 if text.endswith("</musehub_tool_result>"):
185 text = text[: -len("</musehub_tool_result>")].strip()
186 return text
187
188
189 # ── Layer 1 — Unit ────────────────────────────────────────────────────────────
190
191
192 class TestUnitWriteToolCatalogue:
193 def test_write_tool_names_non_empty(self) -> None:
194 assert len(MUSEHUB_WRITE_TOOL_NAMES) > 0
195
196 def test_expected_tools_in_write_set(self) -> None:
197 expected = {
198 "musehub_create_issue",
199 "musehub_create_repo",
200 "musehub_create_proposal",
201 "musehub_merge_proposal",
202 "musehub_create_release",
203 "musehub_create_label",
204 }
205 missing = expected - MUSEHUB_WRITE_TOOL_NAMES
206 assert not missing, f"Tools missing from write set: {missing}"
207
208
209 class TestUnitEarlyValidation:
210 @pytest.mark.anyio
211 async def test_submit_review_invalid_event(self, db_session: AsyncSession) -> None:
212 result = await execute_submit_proposal_review(
213 repo_id="any", proposal_id="any", event="lgtm", reviewer="alice"
214 )
215 assert result.ok is False
216 assert result.error_code == "invalid_args"
217 assert "approve" in (result.error_message or "")
218
219 @pytest.mark.anyio
220 async def test_create_proposal_same_branches(self, db_session: AsyncSession) -> None:
221 result = await execute_create_proposal(
222 repo_id="any", title="T", from_branch="main", to_branch="main", actor="alice"
223 )
224 assert result.ok is False
225 assert "different" in (result.error_message or "").lower()
226
227
228 class TestUnitIssueData:
229 def test_issue_data_produces_correct_keys(self, db_session: AsyncSession) -> None:
230 from musehub.models.musehub import IssueResponse
231
232 issue = IssueResponse(
233 issue_id="iid-1",
234 number=7,
235 title="Test issue",
236 body="Body",
237 state="open",
238 labels=["bug"],
239 author="alice",
240 created_at=datetime.now(tz=timezone.utc),
241 )
242 d = _issue_data(issue)
243 for key in ("issue_id", "number", "title", "body", "state", "labels", "author"):
244 assert key in d, f"Missing key: {key}"
245 assert d["number"] == 7
246 assert d["labels"] == ["bug"]
247
248
249 # ── Layer 2 — Integration ─────────────────────────────────────────────────────
250
251
252 class TestIntegrationCreateIssue:
253 @pytest.mark.anyio
254 async def test_happy_path(self, db_session: AsyncSession) -> None:
255 r = await _repo(db_session)
256 await db_session.commit()
257 result = await execute_create_issue(
258 repo_id=r.repo_id, title="Bass too loud", body="Track 4, bar 12.", actor="alice"
259 )
260 assert result.ok is True
261 assert "issue_id" in result.data
262 assert result.data["number"] >= 1
263 assert result.data["title"] == "Bass too loud"
264
265 @pytest.mark.anyio
266 async def test_unknown_repo(self, db_session: AsyncSession) -> None:
267 result = await execute_create_issue(
268 repo_id="ghost-repo", title="T", actor="alice"
269 )
270 assert result.ok is False
271 assert result.error_code == "repo_not_found"
272
273 @pytest.mark.anyio
274 async def test_with_labels(self, db_session: AsyncSession) -> None:
275 r = await _repo(db_session)
276 await db_session.commit()
277 result = await execute_create_issue(
278 repo_id=r.repo_id,
279 title="Harmony conflict",
280 labels=["harmony", "critical"],
281 actor="alice",
282 )
283 assert result.ok is True
284 assert set(result.data["labels"]) == {"harmony", "critical"}
285
286
287 class TestIntegrationUpdateIssue:
288 @pytest.mark.anyio
289 async def test_update_title(self, db_session: AsyncSession) -> None:
290 r = await _repo(db_session)
291 await db_session.commit()
292 created = await execute_create_issue(
293 repo_id=r.repo_id, title="Old title", actor="alice"
294 )
295 num = created.data["number"]
296
297 result = await execute_update_issue(
298 repo_id=r.repo_id, issue_number=num, title="New title"
299 )
300 assert result.ok is True
301 assert result.data["title"] == "New title"
302
303 @pytest.mark.anyio
304 async def test_close_issue(self, db_session: AsyncSession) -> None:
305 r = await _repo(db_session)
306 await db_session.commit()
307 created = await execute_create_issue(
308 repo_id=r.repo_id, title="To close", actor="alice"
309 )
310 num = created.data["number"]
311
312 result = await execute_update_issue(
313 repo_id=r.repo_id, issue_number=num, state="closed"
314 )
315 assert result.ok is True
316 assert result.data["state"] == "closed"
317
318 @pytest.mark.anyio
319 async def test_unknown_repo(self, db_session: AsyncSession) -> None:
320 result = await execute_update_issue(
321 repo_id="ghost-repo", issue_number=1, title="x"
322 )
323 assert result.ok is False
324 assert result.error_code == "repo_not_found"
325
326
327 class TestIntegrationCreateIssueComment:
328 @pytest.mark.anyio
329 async def test_happy_path(self, db_session: AsyncSession) -> None:
330 r = await _repo(db_session)
331 await db_session.commit()
332 issue = await execute_create_issue(
333 repo_id=r.repo_id, title="I", actor="alice"
334 )
335 num = issue.data["number"]
336
337 result = await execute_create_issue_comment(
338 repo_id=r.repo_id, issue_number=num, body="LGTM!", actor="bob"
339 )
340 assert result.ok is True
341 assert "comment_id" in result.data
342 assert result.data["body"] == "LGTM!"
343
344 @pytest.mark.anyio
345 async def test_unknown_issue(self, db_session: AsyncSession) -> None:
346 r = await _repo(db_session)
347 await db_session.commit()
348 result = await execute_create_issue_comment(
349 repo_id=r.repo_id, issue_number=999, body="x", actor="alice"
350 )
351 assert result.ok is False
352 assert result.error_code == "issue_not_found"
353
354
355 class TestIntegrationCreateProposal:
356 @pytest.mark.anyio
357 async def test_happy_path(self, db_session: AsyncSession) -> None:
358 r = await _repo(db_session)
359 await _commit_and_branch(db_session, r.repo_id, "main")
360 await _commit_and_branch(db_session, r.repo_id, "feat-harmony")
361 await db_session.commit()
362
363 result = await execute_create_proposal(
364 repo_id=r.repo_id,
365 title="Add harmony layer",
366 from_branch="feat-harmony",
367 to_branch="main",
368 body="Adds new harmonic dimension.",
369 actor="alice",
370 )
371 assert result.ok is True
372 assert "proposal_id" in result.data
373 assert result.data["state"] == "open"
374
375 @pytest.mark.anyio
376 async def test_unknown_repo(self, db_session: AsyncSession) -> None:
377 result = await execute_create_proposal(
378 repo_id="ghost-proposal", title="T", from_branch="feat", to_branch="main", actor="alice"
379 )
380 assert result.ok is False
381 assert result.error_code == "repo_not_found"
382
383
384 class TestIntegrationMergeProposal:
385 @pytest.mark.anyio
386 async def test_merge_open_proposal(self, db_session: AsyncSession) -> None:
387 r = await _repo(db_session)
388 await _commit_and_branch(db_session, r.repo_id, "main")
389 await _commit_and_branch(db_session, r.repo_id, "feat-bass")
390 await db_session.commit()
391
392 proposal_result = await execute_create_proposal(
393 repo_id=r.repo_id,
394 title="Add bass line",
395 from_branch="feat-bass",
396 to_branch="main",
397 actor="alice",
398 )
399 assert proposal_result.ok is True
400 proposal_id = proposal_result.data["proposal_id"]
401
402 result = await execute_merge_proposal(repo_id=r.repo_id, proposal_id=proposal_id)
403 assert result.ok is True
404 assert result.data["state"] == "merged"
405
406 @pytest.mark.anyio
407 async def test_merge_unknown_proposal(self, db_session: AsyncSession) -> None:
408 r = await _repo(db_session)
409 result = await execute_merge_proposal(repo_id=r.repo_id, proposal_id="ghost-proposal-id")
410 assert result.ok is False
411
412
413 class TestIntegrationSubmitReview:
414 @pytest.mark.anyio
415 async def test_approve(self, db_session: AsyncSession) -> None:
416 r = await _repo(db_session)
417 await _commit_and_branch(db_session, r.repo_id, "main")
418 await _commit_and_branch(db_session, r.repo_id, "feat-review")
419 await db_session.commit()
420
421 proposal_result = await execute_create_proposal(
422 repo_id=r.repo_id,
423 title="Review test",
424 from_branch="feat-review",
425 to_branch="main",
426 actor="alice",
427 )
428 proposal_id = proposal_result.data["proposal_id"]
429
430 result = await execute_submit_proposal_review(
431 repo_id=r.repo_id, proposal_id=proposal_id, event="approve", reviewer="bob"
432 )
433 assert result.ok is True
434 assert result.data["state"] == "approved"
435
436 @pytest.mark.anyio
437 async def test_request_changes(self, db_session: AsyncSession) -> None:
438 r = await _repo(db_session)
439 await _commit_and_branch(db_session, r.repo_id, "main")
440 await _commit_and_branch(db_session, r.repo_id, "feat-chg")
441 await db_session.commit()
442
443 proposal_result = await execute_create_proposal(
444 repo_id=r.repo_id,
445 title="Changes test",
446 from_branch="feat-chg",
447 to_branch="main",
448 actor="alice",
449 )
450 proposal_id = proposal_result.data["proposal_id"]
451
452 result = await execute_submit_proposal_review(
453 repo_id=r.repo_id, proposal_id=proposal_id, event="request_changes", reviewer="carol"
454 )
455 assert result.ok is True
456 assert result.data["state"] == "changes_requested"
457
458
459 class TestIntegrationCreateRelease:
460 @pytest.mark.anyio
461 async def test_happy_path(self, db_session: AsyncSession) -> None:
462 r = await _repo(db_session)
463 await db_session.commit()
464 result = await execute_create_release(
465 repo_id=r.repo_id,
466 tag="v1.0.0",
467 title="First Release",
468 body="Initial stable release.",
469 channel="stable",
470 actor="alice",
471 )
472 assert result.ok is True
473 assert result.data["tag"] == "v1.0.0"
474 assert result.data["channel"] == "stable"
475 assert "release_id" in result.data
476
477 @pytest.mark.anyio
478 async def test_duplicate_tag_error(self, db_session: AsyncSession) -> None:
479 r = await _repo(db_session)
480 await db_session.commit()
481 await execute_create_release(repo_id=r.repo_id, tag="v1.0.0", actor="alice")
482 result = await execute_create_release(repo_id=r.repo_id, tag="v1.0.0", actor="alice")
483 assert result.ok is False
484
485 @pytest.mark.anyio
486 async def test_beta_channel(self, db_session: AsyncSession) -> None:
487 r = await _repo(db_session)
488 await db_session.commit()
489 result = await execute_create_release(
490 repo_id=r.repo_id, tag="v2.0.0-beta.1", channel="beta", actor="alice"
491 )
492 assert result.ok is True
493 assert result.data["channel"] == "beta"
494
495
496 class TestIntegrationCreateRepo:
497 @pytest.mark.anyio
498 async def test_happy_path(self, db_session: AsyncSession) -> None:
499 result = await execute_create_repo(
500 name="jazz-standards",
501 owner="alice",
502 owner_user_id="uid-alice",
503 description="My jazz collection",
504 visibility="public",
505 )
506 assert result.ok is True
507 assert "repo_id" in result.data
508 assert result.data["owner"] == "alice"
509 assert "jazz" in result.data["slug"]
510
511 @pytest.mark.anyio
512 async def test_private_visibility(self, db_session: AsyncSession) -> None:
513 result = await execute_create_repo(
514 name="private-session",
515 owner="alice",
516 owner_user_id="uid-alice",
517 visibility="private",
518 )
519 assert result.ok is True
520 assert result.data["visibility"] == "private"
521
522
523 class TestIntegrationCreateLabel:
524 @pytest.mark.anyio
525 async def test_happy_path(self, db_session: AsyncSession) -> None:
526 r = await _repo(db_session)
527 await db_session.commit()
528 result = await execute_create_label(
529 repo_id=r.repo_id, name="harmony", color="e11d48", actor="alice"
530 )
531 assert result.ok is True
532 assert "label_id" in result.data
533 assert result.data["name"] == "harmony"
534 assert result.data["color"] == "e11d48"
535
536 @pytest.mark.anyio
537 async def test_duplicate_name_rejected(self, db_session: AsyncSession) -> None:
538 r = await _repo(db_session)
539 await db_session.commit()
540 await execute_create_label(repo_id=r.repo_id, name="dup-label", color="aabbcc")
541 result = await execute_create_label(
542 repo_id=r.repo_id, name="dup-label", color="112233"
543 )
544 assert result.ok is False
545 assert "already exists" in (result.error_message or "").lower()
546
547
548 # ── Layer 3 — End-to-End ──────────────────────────────────────────────────────
549
550
551 class TestE2EAuthGate:
552 """Every write tool call without auth must return 401."""
553
554 @pytest.mark.anyio
555 async def test_create_issue_no_auth(
556 self, http_client: AsyncClient, db_session: AsyncSession
557 ) -> None:
558 r = await _repo(db_session)
559 resp = await http_client.post(
560 "/mcp",
561 json=_tools_call("musehub_create_issue", {"repo_id": r.repo_id, "title": "T"}),
562 headers={"Content-Type": "application/json"},
563 )
564 assert resp.status_code == 401
565
566 @pytest.mark.anyio
567 async def test_create_repo_no_auth(
568 self, http_client: AsyncClient, db_session: AsyncSession
569 ) -> None:
570 resp = await http_client.post(
571 "/mcp",
572 json=_tools_call("musehub_create_repo", {"name": "test-repo"}),
573 headers={"Content-Type": "application/json"},
574 )
575 assert resp.status_code == 401
576
577 @pytest.mark.anyio
578 async def test_create_proposal_no_auth(
579 self, http_client: AsyncClient, db_session: AsyncSession
580 ) -> None:
581 resp = await http_client.post(
582 "/mcp",
583 json=_tools_call("musehub_create_proposal", {
584 "repo_id": "x", "title": "T", "from_branch": "a", "to_branch": "b"
585 }),
586 headers={"Content-Type": "application/json"},
587 )
588 assert resp.status_code == 401
589
590 @pytest.mark.anyio
591 async def test_merge_proposal_no_auth(
592 self, http_client: AsyncClient, db_session: AsyncSession
593 ) -> None:
594 resp = await http_client.post(
595 "/mcp",
596 json=_tools_call("musehub_merge_proposal", {"repo_id": "x", "proposal_id": "y"}),
597 headers={"Content-Type": "application/json"},
598 )
599 assert resp.status_code == 401
600
601 @pytest.mark.anyio
602 async def test_create_release_no_auth(
603 self, http_client: AsyncClient, db_session: AsyncSession
604 ) -> None:
605 resp = await http_client.post(
606 "/mcp",
607 json=_tools_call("musehub_create_release", {"repo_id": "x", "tag": "v1.0", "title": "R"}),
608 headers={"Content-Type": "application/json"},
609 )
610 assert resp.status_code == 401
611
612
613
614 class TestE2EAuthenticatedWrite:
615 """Write tools succeed when auth is present."""
616
617 @pytest.mark.anyio
618 async def test_create_issue_with_auth(
619 self, http_client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
620 ) -> None:
621 r = await _repo(db_session)
622 await db_session.commit()
623
624 resp = await http_client.post(
625 "/mcp",
626 json=_tools_call("musehub_create_issue", {
627 "repo_id": r.repo_id, "title": "Auth issue"
628 }),
629 headers=auth_headers,
630 )
631 assert resp.status_code == 200
632 result = resp.json()["result"]
633 assert result["isError"] is False
634 payload = json.loads(_unwrap_tool_text(result["content"][0]["text"]))
635 assert payload["title"] == "Auth issue"
636
637 @pytest.mark.anyio
638 async def test_create_repo_with_auth(
639 self, http_client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
640 ) -> None:
641 resp = await http_client.post(
642 "/mcp",
643 json=_tools_call("musehub_create_repo", {
644 "name": "e2e-authed-repo",
645 "owner": "testuser",
646 }),
647 headers=auth_headers,
648 )
649 assert resp.status_code == 200
650 result = resp.json()["result"]
651 assert result["isError"] is False
652 payload = json.loads(_unwrap_tool_text(result["content"][0]["text"]))
653 assert "repo_id" in payload
654
655
656 # ── Layer 4 — Stress ──────────────────────────────────────────────────────────
657
658
659 class TestStressWriteTools:
660 @pytest.mark.anyio
661 async def test_20_sequential_issues_have_sequential_numbers(
662 self, db_session: AsyncSession
663 ) -> None:
664 r = await _repo(db_session)
665 await db_session.commit()
666 numbers = []
667 for i in range(20):
668 result = await execute_create_issue(
669 repo_id=r.repo_id, title=f"Issue {i}", actor="alice"
670 )
671 assert result.ok is True
672 numbers.append(result.data["number"])
673 assert numbers == list(range(1, 21))
674
675
676
677 # ── Layer 5 — Data Integrity ──────────────────────────────────────────────────
678
679
680 class TestDataIntegrityIssue:
681 @pytest.mark.anyio
682 async def test_created_issue_retrievable(self, db_session: AsyncSession) -> None:
683 r = await _repo(db_session)
684 await db_session.commit()
685 await execute_create_issue(
686 repo_id=r.repo_id, title="Retrievable issue", actor="alice"
687 )
688 issues_result = await execute_list_issues(r.repo_id, state="open")
689 assert issues_result.ok is True
690 titles = [i["title"] for i in issues_result.data["issues"]]
691 assert "Retrievable issue" in titles
692
693 @pytest.mark.anyio
694 async def test_closed_issue_state_persisted(self, db_session: AsyncSession) -> None:
695 r = await _repo(db_session)
696 await db_session.commit()
697 created = await execute_create_issue(
698 repo_id=r.repo_id, title="Close me", actor="alice"
699 )
700 num = created.data["number"]
701
702 await execute_update_issue(repo_id=r.repo_id, issue_number=num, state="closed")
703
704 issues_result = await execute_list_issues(r.repo_id, state="closed")
705 assert issues_result.ok is True
706 numbers = [i["number"] for i in issues_result.data["issues"]]
707 assert num in numbers
708
709
710 class TestDataIntegrityProposal:
711 @pytest.mark.anyio
712 async def test_created_proposal_retrievable(self, db_session: AsyncSession) -> None:
713 r = await _repo(db_session)
714 await _commit_and_branch(db_session, r.repo_id, "main")
715 await _commit_and_branch(db_session, r.repo_id, "feat-di")
716 await db_session.commit()
717
718 proposal_result = await execute_create_proposal(
719 repo_id=r.repo_id,
720 title="DI proposal",
721 from_branch="feat-di",
722 to_branch="main",
723 actor="alice",
724 )
725 assert proposal_result.ok is True
726
727 list_result = await execute_list_proposals(r.repo_id, state="open")
728 assert list_result.ok is True
729 titles = [p["title"] for p in list_result.data["proposals"]]
730 assert "DI proposal" in titles
731
732
733 class TestDataIntegrityRelease:
734 @pytest.mark.anyio
735 async def test_release_tag_persisted(self, db_session: AsyncSession) -> None:
736 r = await _repo(db_session)
737 await db_session.commit()
738 result = await execute_create_release(
739 repo_id=r.repo_id, tag="v3.7.2", title="Minor release", actor="alice"
740 )
741 assert result.ok is True
742 assert result.data["tag"] == "v3.7.2"
743
744 @pytest.mark.anyio
745 async def test_release_author_persisted(self, db_session: AsyncSession) -> None:
746 r = await _repo(db_session)
747 await db_session.commit()
748 result = await execute_create_release(
749 repo_id=r.repo_id, tag="v0.1.0", actor="carol"
750 )
751 assert result.ok is True
752 assert result.data["author"] == "carol"
753
754
755 # ── Layer 6 — Security ────────────────────────────────────────────────────────
756
757
758 class TestSecurityWriteTools:
759 def test_all_write_tools_in_auth_gate_set(self) -> None:
760 """All MUSEHUB_WRITE_TOOLS must be in MUSEHUB_WRITE_TOOL_NAMES."""
761 from musehub.mcp.tools.musehub import MUSEHUB_WRITE_TOOLS
762 write_tool_names_from_list = {t["name"] for t in MUSEHUB_WRITE_TOOLS}
763 # All write tools in the list should be in the gate set.
764 uncovered = write_tool_names_from_list - MUSEHUB_WRITE_TOOL_NAMES
765 assert not uncovered, f"Write tools not in auth gate: {uncovered}"
766
767 @pytest.mark.anyio
768 async def test_create_proposal_same_branch_guard(self, db_session: AsyncSession) -> None:
769 r = await _repo(db_session)
770 await db_session.commit()
771 result = await execute_create_proposal(
772 repo_id=r.repo_id, title="T", from_branch="main", to_branch="main", actor="alice"
773 )
774 assert result.ok is False
775
776 @pytest.mark.anyio
777 async def test_duplicate_label_rejected(self, db_session: AsyncSession) -> None:
778 r = await _repo(db_session)
779 await db_session.commit()
780 await execute_create_label(repo_id=r.repo_id, name="sec-label", color="aabbcc")
781 result = await execute_create_label(
782 repo_id=r.repo_id, name="sec-label", color="ddeeff"
783 )
784 assert result.ok is False
785
786 @pytest.mark.anyio
787 async def test_create_issue_comment_on_wrong_issue_safe(
788 self, db_session: AsyncSession
789 ) -> None:
790 """create_issue_comment on non-existent issue must return not_found, not crash."""
791 r = await _repo(db_session)
792 await db_session.commit()
793 result = await execute_create_issue_comment(
794 repo_id=r.repo_id, issue_number=9999, body="hack", actor="eve"
795 )
796 assert result.ok is False
797 assert result.error_code == "issue_not_found"
798
799
800 # ── Layer 7 — Performance ─────────────────────────────────────────────────────
801
802
803 class TestPerformanceWriteTools:
804 @pytest.mark.anyio
805 async def test_10_sequential_issues_under_500ms(
806 self, db_session: AsyncSession
807 ) -> None:
808 r = await _repo(db_session)
809 await db_session.commit()
810 start = time.perf_counter()
811 for i in range(10):
812 result = await execute_create_issue(
813 repo_id=r.repo_id, title=f"Perf issue {i}", actor="alice"
814 )
815 assert result.ok is True
816 elapsed_ms = (time.perf_counter() - start) * 1000
817 assert elapsed_ms < 500, f"10 issues took {elapsed_ms:.1f} ms"
818
819 @pytest.mark.anyio
820 async def test_5_sequential_repos_under_1000ms(
821 self, db_session: AsyncSession
822 ) -> None:
823 start = time.perf_counter()
824 for i in range(5):
825 result = await execute_create_repo(
826 name=f"perf-repo-{i}",
827 owner="alice",
828 owner_user_id="uid-alice",
829 initialize=False,
830 )
831 assert result.ok is True
832 elapsed_ms = (time.perf_counter() - start) * 1000
833 assert elapsed_ms < 1000, f"5 repos took {elapsed_ms:.1f} ms"
File History 3 commits
sha256:d8cbca3a06f39f82f66be6c29de3f41c3dec5f367722958fb5454dcbc007cc15 fix: rc11 test fixes — event→verdict rename, migration coun… Sonnet 4.6 patch 15 days ago
sha256:af9422a68cbd2db7c88f664388e11134b0ae0057ee5ad14465d82208548a9d7d changing --event to --verdict. displaying changes requested… Human minor 15 days ago
sha256:f93f34a08d829b55b7324c2dacfa517618560ddfb11c09a1120f325fb6a238af fix: migration downgrade guards and test timing budgets Sonnet 4.6 patch 21 days ago