gabriel / musehub public

test_mcp_write_tools.py file-level

at sha256:8 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:4 fix: publish_muse_release.sh failed against production on two counts D… · gabriel · Sep 12, 2026
1 """Section 15 — MCP Write Tools: 7-layer test suite.
2
3 Covers ``musehub/mcp/write_tools/`` — issues, proposals, releases, repos, labels.
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_create_proposal_review,
8 execute_create_proposal_comment
9 execute_create_release
10 execute_create_repo
11 execute_create_label, execute_update_label, execute_delete_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_create_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_create_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 secrets
63 import time
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 muse.core.types import fake_id
73 from musehub.core.genesis import compute_branch_id, compute_identity_id, compute_issue_id, compute_repo_id
74 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, MusehubRepo
75 from musehub.main import app
76 from musehub.mcp.tools.musehub import MUSEHUB_WRITE_TOOL_NAMES
77 from musehub.types.json_types import JSONObject, StrDict
78 from musehub.mcp.write_tools.issues import (
79 _issue_data,
80 execute_close_issue,
81 execute_create_issue,
82 execute_create_issue_comment,
83 execute_reopen_issue,
84 execute_assign_issue,
85 execute_update_issue_labels,
86 execute_remove_issue_label,
87 execute_update_issue,
88 )
89 from musehub.mcp.write_tools.proposals import (
90 execute_create_proposal,
91 execute_create_proposal_comment,
92 execute_merge_proposal,
93 execute_create_proposal_review,
94 execute_list_proposal_comments,
95 execute_request_proposal_reviewers,
96 execute_remove_proposal_reviewer,
97 execute_list_proposal_reviews,
98 )
99 from musehub.mcp.write_tools.releases import (
100 execute_create_release,
101 execute_attach_release_asset,
102 execute_delete_release_asset,
103 )
104 from musehub.mcp.write_tools.repos import (
105 execute_create_repo,
106 execute_delete_repo,
107 execute_update_repo,
108 execute_transfer_repo_ownership,
109 )
110 from musehub.mcp.write_tools.labels import (
111 execute_create_label,
112 execute_delete_label,
113 execute_update_label,
114 )
115 from musehub.mcp.write_tools.collaborators import (
116 execute_accept_collaborator_invite,
117 execute_invite_collaborator,
118 execute_list_collaborators,
119 execute_remove_collaborator,
120 execute_update_collaborator_permission,
121 )
122 from musehub.mcp.write_tools.webhooks import (
123 execute_create_webhook,
124 execute_delete_webhook,
125 execute_list_webhooks,
126 )
127 from musehub.mcp.write_tools.issues import execute_delete_issue_comment
128 from musehub.services.musehub_mcp_executor import (
129 execute_list_issues,
130 execute_list_labels,
131 execute_list_proposals,
132 )
133
134
135 # ── Fixtures ──────────────────────────────────────────────────────────────────
136
137
138 @pytest.fixture
139 def anyio_backend() -> str:
140 return "asyncio"
141
142
143 @pytest_asyncio.fixture
144 async def http_client(db_session: AsyncSession) -> AsyncClient:
145 async with AsyncClient(
146 transport=ASGITransport(app=app),
147 base_url="http://localhost",
148 ) as c:
149 yield c
150
151
152 # ── Helpers ───────────────────────────────────────────────────────────────────
153
154
155 def _uid() -> str:
156 return secrets.token_hex(16)
157
158
159 def _slug() -> str:
160 return f"repo-{secrets.token_hex(4)}"
161
162
163 async def _repo(
164 session: AsyncSession,
165 slug: str | None = None,
166 visibility: str = "public",
167 owner: str = "alice",
168 ) -> MusehubRepo:
169 name = slug or _slug()
170 created_at = datetime.now(tz=timezone.utc)
171 owner_id = compute_identity_id(owner.encode())
172 r = MusehubRepo(
173 repo_id=compute_repo_id(owner_id, name, "code", created_at.isoformat()),
174 name=name,
175 owner=owner,
176 slug=name,
177 visibility=visibility,
178 owner_user_id=owner_id,
179 created_at=created_at,
180 updated_at=created_at,
181 )
182 session.add(r)
183 await session.flush()
184 await session.refresh(r)
185 return r
186
187
188 async def _commit_and_branch(
189 session: AsyncSession,
190 repo_id: str,
191 branch: str = "main",
192 ) -> MusehubCommit:
193 c = MusehubCommit(
194 commit_id=fake_id(f"{repo_id}{branch}{secrets.token_hex(4)}"),
195 branch=branch,
196 parent_ids=[],
197 message="init",
198 author="alice",
199 timestamp=datetime.now(tz=timezone.utc),
200 )
201 b = MusehubBranch(
202 branch_id=compute_branch_id(repo_id, branch),
203 repo_id=repo_id,
204 name=branch,
205 head_commit_id=c.commit_id,
206 )
207 session.add(c)
208 session.add(MusehubCommitRef(repo_id=repo_id, commit_id=c.commit_id))
209 session.add(b)
210 await session.flush()
211 return c
212
213
214 def _tools_call(name: str, arguments: JSONObject) -> JSONObject:
215 return {
216 "jsonrpc": "2.0",
217 "id": 1,
218 "method": "tools/call",
219 "params": {"name": name, "arguments": arguments},
220 }
221
222
223 def _unwrap_tool_text(text: str) -> str:
224 """Strip <musehub_tool_result> wrapper tags added by the dispatcher."""
225 text = text.strip()
226 if text.startswith("<musehub_tool_result>"):
227 text = text[len("<musehub_tool_result>"):].strip()
228 if text.endswith("</musehub_tool_result>"):
229 text = text[: -len("</musehub_tool_result>")].strip()
230 return text
231
232
233 # ── Layer 1 — Unit ────────────────────────────────────────────────────────────
234
235
236 class TestUnitWriteToolCatalogue:
237 def test_write_tool_names_non_empty(self) -> None:
238 assert len(MUSEHUB_WRITE_TOOL_NAMES) > 0
239
240 def test_expected_tools_in_write_set(self) -> None:
241 expected = {
242 "musehub_create_issue",
243 "musehub_create_repo",
244 "musehub_create_proposal",
245 "musehub_merge_proposal",
246 "musehub_create_release",
247 "musehub_create_label",
248 "musehub_update_label",
249 "musehub_delete_label",
250 }
251 missing = expected - MUSEHUB_WRITE_TOOL_NAMES
252 assert not missing, f"Tools missing from write set: {missing}"
253
254
255 class TestUnitEarlyValidation:
256 async def test_submit_review_invalid_event(self, db_session: AsyncSession) -> None:
257 result = await execute_create_proposal_review(
258 repo_id="any", proposal_id="any", verdict="lgtm", reviewer="alice"
259 )
260 assert result.ok is False
261 assert result.error_code == "invalid_args"
262 assert "approve" in (result.error_message or "")
263
264 async def test_create_proposal_same_branches(self, db_session: AsyncSession) -> None:
265 result = await execute_create_proposal(
266 repo_id="any", title="T", from_branch="main", to_branch="main", actor="alice"
267 )
268 assert result.ok is False
269 assert "different" in (result.error_message or "").lower()
270
271
272 class TestUnitIssueData:
273 def test_issue_data_produces_correct_keys(self, db_session: AsyncSession) -> None:
274 from musehub.models.musehub import IssueResponse
275
276 _now = datetime.now(tz=timezone.utc)
277 issue = IssueResponse(
278 issue_id=compute_issue_id(fake_id("repo"), compute_identity_id(b"alice"), _now.isoformat()),
279 number=7,
280 title="Test issue",
281 body="Body",
282 state="open",
283 labels=["bug"],
284 author="alice",
285 created_at=_now,
286 )
287 d = _issue_data(issue)
288 for key in ("issue_id", "number", "title", "body", "state", "labels", "author"):
289 assert key in d, f"Missing key: {key}"
290 assert d["number"] == 7
291 assert d["labels"] == ["bug"]
292
293
294 # ── Layer 2 — Integration ─────────────────────────────────────────────────────
295
296
297 class TestIntegrationCreateIssue:
298 async def test_happy_path(self, db_session: AsyncSession) -> None:
299 r = await _repo(db_session)
300 await db_session.commit()
301 result = await execute_create_issue(
302 repo_id=r.repo_id, title="Bass too loud", body="Track 4, bar 12.", actor="alice"
303 )
304 assert result.ok is True
305 assert "issue_id" in result.data
306 assert result.data["number"] >= 1
307 assert result.data["title"] == "Bass too loud"
308
309 async def test_any_user_can_create_issue_on_public_repo(self, db_session: AsyncSession) -> None:
310 r = await _repo(db_session, visibility="public")
311 await db_session.commit()
312 result = await execute_create_issue(
313 repo_id=r.repo_id, title="Public comment", actor="carol"
314 )
315 assert result.ok is True
316
317 async def test_forbidden_on_private_repo_for_non_owner(self, db_session: AsyncSession) -> None:
318 r = await _repo(db_session, visibility="private")
319 await db_session.commit()
320 result = await execute_create_issue(
321 repo_id=r.repo_id, title="Private issue", actor="carol"
322 )
323 assert result.ok is False
324 assert result.error_code == "forbidden"
325
326 async def test_forbidden_without_auth(self, db_session: AsyncSession) -> None:
327 r = await _repo(db_session)
328 await db_session.commit()
329 result = await execute_create_issue(
330 repo_id=r.repo_id, title="T", actor=""
331 )
332 assert result.ok is False
333 assert result.error_code == "forbidden"
334
335 async def test_unknown_repo(self, db_session: AsyncSession) -> None:
336 result = await execute_create_issue(
337 repo_id=fake_id("ghost-repo"), title="T", actor="alice"
338 )
339 assert result.ok is False
340 assert result.error_code == "repo_not_found"
341
342 async def test_with_labels(self, db_session: AsyncSession) -> None:
343 r = await _repo(db_session)
344 await db_session.commit()
345 result = await execute_create_issue(
346 repo_id=r.repo_id,
347 title="Harmony conflict",
348 labels=["harmony", "critical"],
349 actor="alice",
350 )
351 assert result.ok is True
352 assert set(result.data["labels"]) == {"harmony", "critical"}
353
354
355 class TestIntegrationUpdateIssue:
356 async def test_update_title(self, db_session: AsyncSession) -> None:
357 r = await _repo(db_session)
358 await db_session.commit()
359 created = await execute_create_issue(
360 repo_id=r.repo_id, title="Old title", actor="alice"
361 )
362 num = created.data["number"]
363
364 result = await execute_update_issue(
365 repo_id=r.repo_id, issue_number=num, title="New title", actor="alice"
366 )
367 assert result.ok is True
368 assert result.data["title"] == "New title"
369
370 async def test_close_issue(self, db_session: AsyncSession) -> None:
371 r = await _repo(db_session)
372 await db_session.commit()
373 created = await execute_create_issue(
374 repo_id=r.repo_id, title="To close", actor="alice"
375 )
376 num = created.data["number"]
377
378 result = await execute_update_issue(
379 repo_id=r.repo_id, issue_number=num, state="closed", actor="alice"
380 )
381 assert result.ok is True
382 assert result.data["state"] == "closed"
383
384 async def test_unknown_repo(self, db_session: AsyncSession) -> None:
385 result = await execute_update_issue(
386 repo_id=fake_id("ghost-repo"), issue_number=1, title="x", actor="alice"
387 )
388 assert result.ok is False
389 assert result.error_code == "repo_not_found"
390
391 async def test_forbidden_without_auth(self, db_session: AsyncSession) -> None:
392 r = await _repo(db_session)
393 await db_session.commit()
394 result = await execute_update_issue(
395 repo_id=r.repo_id, issue_number=1, title="x", actor=""
396 )
397 assert result.ok is False
398 assert result.error_code == "forbidden"
399
400 async def test_forbidden_non_collaborator(self, db_session: AsyncSession) -> None:
401 r = await _repo(db_session)
402 await db_session.commit()
403 created = await execute_create_issue(
404 repo_id=r.repo_id, title="Some issue", actor="alice"
405 )
406 num = created.data["number"]
407 result = await execute_update_issue(
408 repo_id=r.repo_id, issue_number=num, title="x", actor="carol"
409 )
410 assert result.ok is False
411 assert result.error_code == "forbidden"
412
413
414 class TestIntegrationCreateIssueComment:
415 async def test_happy_path(self, db_session: AsyncSession) -> None:
416 r = await _repo(db_session, visibility="public")
417 await db_session.commit()
418 issue = await execute_create_issue(
419 repo_id=r.repo_id, title="I", actor="alice"
420 )
421 num = issue.data["number"]
422
423 # Any authenticated user can comment on a public repo
424 result = await execute_create_issue_comment(
425 repo_id=r.repo_id, issue_number=num, body="LGTM!", actor="bob"
426 )
427 assert result.ok is True
428 assert "comment_id" in result.data
429 assert result.data["body"] == "LGTM!"
430
431 async def test_forbidden_without_auth(self, db_session: AsyncSession) -> None:
432 r = await _repo(db_session)
433 await db_session.commit()
434 result = await execute_create_issue_comment(
435 repo_id=r.repo_id, issue_number=1, body="x", actor=""
436 )
437 assert result.ok is False
438 assert result.error_code == "forbidden"
439
440 async def test_forbidden_on_private_repo_for_non_owner(self, db_session: AsyncSession) -> None:
441 r = await _repo(db_session, visibility="private")
442 await db_session.commit()
443 issue = await execute_create_issue(repo_id=r.repo_id, title="I", actor="alice")
444 num = issue.data["number"]
445 result = await execute_create_issue_comment(
446 repo_id=r.repo_id, issue_number=num, body="x", actor="carol"
447 )
448 assert result.ok is False
449 assert result.error_code == "forbidden"
450
451 async def test_unknown_issue(self, db_session: AsyncSession) -> None:
452 r = await _repo(db_session)
453 await db_session.commit()
454 result = await execute_create_issue_comment(
455 repo_id=r.repo_id, issue_number=999, body="x", actor="alice"
456 )
457 assert result.ok is False
458 assert result.error_code == "issue_not_found"
459
460
461 class TestIntegrationCreateProposal:
462 async def test_happy_path(self, db_session: AsyncSession) -> None:
463 r = await _repo(db_session)
464 await _commit_and_branch(db_session, r.repo_id, "main")
465 await _commit_and_branch(db_session, r.repo_id, "feat-harmony")
466 await db_session.commit()
467
468 result = await execute_create_proposal(
469 repo_id=r.repo_id,
470 title="Add harmony layer",
471 from_branch="feat-harmony",
472 to_branch="main",
473 body="Adds new harmonic dimension.",
474 actor="alice",
475 )
476 assert result.ok is True
477 assert "proposal_id" in result.data
478 assert result.data["state"] == "open"
479
480 async def test_any_user_can_create_proposal_on_public_repo(self, db_session: AsyncSession) -> None:
481 r = await _repo(db_session, visibility="public")
482 await _commit_and_branch(db_session, r.repo_id, "main")
483 await _commit_and_branch(db_session, r.repo_id, "feat-pub")
484 await db_session.commit()
485 result = await execute_create_proposal(
486 repo_id=r.repo_id, title="Public PR", from_branch="feat-pub", to_branch="main", actor="carol"
487 )
488 assert result.ok is True
489
490 async def test_forbidden_on_private_repo_for_non_owner(self, db_session: AsyncSession) -> None:
491 r = await _repo(db_session, visibility="private")
492 await _commit_and_branch(db_session, r.repo_id, "main")
493 await _commit_and_branch(db_session, r.repo_id, "feat-prv")
494 await db_session.commit()
495 result = await execute_create_proposal(
496 repo_id=r.repo_id, title="Private PR", from_branch="feat-prv", to_branch="main", actor="carol"
497 )
498 assert result.ok is False
499 assert result.error_code == "forbidden"
500
501 async def test_forbidden_without_auth(self, db_session: AsyncSession) -> None:
502 r = await _repo(db_session)
503 await _commit_and_branch(db_session, r.repo_id, "main")
504 await _commit_and_branch(db_session, r.repo_id, "feat-noauth")
505 await db_session.commit()
506 result = await execute_create_proposal(
507 repo_id=r.repo_id, title="T", from_branch="feat-noauth", to_branch="main", actor=""
508 )
509 assert result.ok is False
510 assert result.error_code == "forbidden"
511
512 async def test_unknown_repo(self, db_session: AsyncSession) -> None:
513 result = await execute_create_proposal(
514 repo_id=fake_id("ghost-proposal"), title="T", from_branch="feat", to_branch="main", actor="alice"
515 )
516 assert result.ok is False
517 assert result.error_code == "repo_not_found"
518
519
520 class TestProposalMergeAccessGuard:
521 """Ownership checks on execute_merge_proposal."""
522
523 async def test_merge_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
524 """Non-owner actor receives error_code='forbidden' from execute_merge_proposal."""
525 r = await _repo(db_session, owner="alice")
526 await _commit_and_branch(db_session, r.repo_id, "main")
527 await _commit_and_branch(db_session, r.repo_id, "feat-x")
528 await db_session.commit()
529
530 proposal_result = await execute_create_proposal(
531 repo_id=r.repo_id,
532 title="Non-owner merge attempt",
533 from_branch="feat-x",
534 to_branch="main",
535 actor="alice",
536 )
537 assert proposal_result.ok is True
538 proposal_id = proposal_result.data["proposal_id"]
539
540 # "carol" is not the owner and has no collaborator entry.
541 result = await execute_merge_proposal(repo_id=r.repo_id, proposal_id=proposal_id, actor="carol")
542 assert result.ok is False
543 assert result.error_code == "forbidden"
544
545 async def test_merge_forbidden_when_unauthenticated(self, db_session: AsyncSession) -> None:
546 """Empty actor string (unauthenticated) receives error_code='forbidden'."""
547 r = await _repo(db_session, owner="alice")
548 await _commit_and_branch(db_session, r.repo_id, "main")
549 await _commit_and_branch(db_session, r.repo_id, "feat-anon")
550 await db_session.commit()
551
552 proposal_result = await execute_create_proposal(
553 repo_id=r.repo_id,
554 title="Unauthenticated merge attempt",
555 from_branch="feat-anon",
556 to_branch="main",
557 actor="alice",
558 )
559 assert proposal_result.ok is True
560 proposal_id = proposal_result.data["proposal_id"]
561
562 result = await execute_merge_proposal(repo_id=r.repo_id, proposal_id=proposal_id, actor="")
563 assert result.ok is False
564 assert result.error_code == "forbidden"
565
566
567 class TestIntegrationMergeProposal:
568 async def test_merge_open_proposal(self, db_session: AsyncSession) -> None:
569 r = await _repo(db_session)
570 await _commit_and_branch(db_session, r.repo_id, "main")
571 await _commit_and_branch(db_session, r.repo_id, "feat-bass")
572 await db_session.commit()
573
574 proposal_result = await execute_create_proposal(
575 repo_id=r.repo_id,
576 title="Add bass line",
577 from_branch="feat-bass",
578 to_branch="main",
579 actor="alice",
580 )
581 assert proposal_result.ok is True
582 proposal_id = proposal_result.data["proposal_id"]
583
584 result = await execute_merge_proposal(repo_id=r.repo_id, proposal_id=proposal_id, actor="alice")
585 assert result.ok is True
586 assert result.data["state"] == "merged"
587
588 async def test_merge_unknown_proposal(self, db_session: AsyncSession) -> None:
589 r = await _repo(db_session)
590 result = await execute_merge_proposal(repo_id=r.repo_id, proposal_id="ghost-proposal-id", actor="alice")
591 assert result.ok is False
592
593
594 class TestIntegrationSubmitReview:
595 async def test_approve(self, db_session: AsyncSession) -> None:
596 r = await _repo(db_session)
597 await _commit_and_branch(db_session, r.repo_id, "main")
598 await _commit_and_branch(db_session, r.repo_id, "feat-review")
599 await db_session.commit()
600
601 proposal_result = await execute_create_proposal(
602 repo_id=r.repo_id,
603 title="Review test",
604 from_branch="feat-review",
605 to_branch="main",
606 actor="alice",
607 )
608 proposal_id = proposal_result.data["proposal_id"]
609
610 result = await execute_create_proposal_review(
611 repo_id=r.repo_id, proposal_id=proposal_id, verdict="approve", reviewer="bob"
612 )
613 assert result.ok is True
614 assert result.data["state"] == "approved"
615
616 async def test_request_changes(self, db_session: AsyncSession) -> None:
617 r = await _repo(db_session)
618 await _commit_and_branch(db_session, r.repo_id, "main")
619 await _commit_and_branch(db_session, r.repo_id, "feat-chg")
620 await db_session.commit()
621
622 proposal_result = await execute_create_proposal(
623 repo_id=r.repo_id,
624 title="Changes test",
625 from_branch="feat-chg",
626 to_branch="main",
627 actor="alice",
628 )
629 proposal_id = proposal_result.data["proposal_id"]
630
631 result = await execute_create_proposal_review(
632 repo_id=r.repo_id, proposal_id=proposal_id, verdict="request_changes", reviewer="carol"
633 )
634 assert result.ok is True
635 assert result.data["state"] == "changes_requested"
636
637 async def test_forbidden_without_auth(self, db_session: AsyncSession) -> None:
638 """Empty reviewer (unauthenticated) must be rejected before any DB access."""
639 r = await _repo(db_session)
640 await db_session.commit()
641 result = await execute_create_proposal_review(
642 repo_id=r.repo_id, proposal_id="any-id", verdict="approve", reviewer=""
643 )
644 assert result.ok is False
645 assert result.error_code == "forbidden"
646
647 async def test_forbidden_on_private_repo_for_non_member(self, db_session: AsyncSession) -> None:
648 """Non-member carol cannot review proposals on a private repo."""
649 r = await _repo(db_session, visibility="private")
650 await _commit_and_branch(db_session, r.repo_id, "main")
651 await _commit_and_branch(db_session, r.repo_id, "feat-prv")
652 await db_session.commit()
653
654 proposal_result = await execute_create_proposal(
655 repo_id=r.repo_id,
656 title="Private review test",
657 from_branch="feat-prv",
658 to_branch="main",
659 actor="alice",
660 )
661 proposal_id = proposal_result.data["proposal_id"]
662
663 result = await execute_create_proposal_review(
664 repo_id=r.repo_id, proposal_id=proposal_id, verdict="approve", reviewer="carol"
665 )
666 assert result.ok is False
667 assert result.error_code == "forbidden"
668
669 async def test_any_user_can_review_public_repo(self, db_session: AsyncSession) -> None:
670 """Any authenticated user may submit a review on a public repo."""
671 r = await _repo(db_session, visibility="public")
672 await _commit_and_branch(db_session, r.repo_id, "main")
673 await _commit_and_branch(db_session, r.repo_id, "feat-pub")
674 await db_session.commit()
675
676 proposal_result = await execute_create_proposal(
677 repo_id=r.repo_id,
678 title="Public review test",
679 from_branch="feat-pub",
680 to_branch="main",
681 actor="alice",
682 )
683 proposal_id = proposal_result.data["proposal_id"]
684
685 result = await execute_create_proposal_review(
686 repo_id=r.repo_id, proposal_id=proposal_id, verdict="request_changes", reviewer="dave"
687 )
688 assert result.ok is True
689 assert result.data["state"] in ("pending", "changes_requested")
690
691
692 class TestIntegrationCreateRelease:
693 async def test_happy_path(self, db_session: AsyncSession) -> None:
694 r = await _repo(db_session)
695 await db_session.commit()
696 result = await execute_create_release(
697 repo_id=r.repo_id,
698 tag="v1.0.0",
699 title="First Release",
700 body="Initial stable release.",
701 channel="stable",
702 actor="alice",
703 )
704 assert result.ok is True
705 assert result.data["tag"] == "v1.0.0"
706 assert result.data["channel"] == "stable"
707 assert "release_id" in result.data
708
709 async def test_duplicate_tag_error(self, db_session: AsyncSession) -> None:
710 r = await _repo(db_session)
711 await db_session.commit()
712 await execute_create_release(repo_id=r.repo_id, tag="v1.0.0", actor="alice")
713 result = await execute_create_release(repo_id=r.repo_id, tag="v1.0.0", actor="alice")
714 assert result.ok is False
715
716 async def test_beta_channel(self, db_session: AsyncSession) -> None:
717 r = await _repo(db_session)
718 await db_session.commit()
719 result = await execute_create_release(
720 repo_id=r.repo_id, tag="v2.0.0-beta.1", channel="beta", actor="alice"
721 )
722 assert result.ok is True
723 assert result.data["channel"] == "beta"
724
725
726 class TestIntegrationCreateRepo:
727 async def test_happy_path(self, db_session: AsyncSession) -> None:
728 result = await execute_create_repo(
729 name="jazz-standards",
730 owner="alice",
731 owner_user_id=compute_identity_id(b"alice"),
732 description="My jazz collection",
733 visibility="public",
734 )
735 assert result.ok is True
736 assert "repo_id" in result.data
737 assert result.data["owner"] == "alice"
738 assert "jazz" in result.data["slug"]
739
740 async def test_private_visibility(self, db_session: AsyncSession) -> None:
741 result = await execute_create_repo(
742 name="private-session",
743 owner="alice",
744 owner_user_id=compute_identity_id(b"alice"),
745 visibility="private",
746 )
747 assert result.ok is True
748 assert result.data["visibility"] == "private"
749
750 async def test_forbidden_when_unauthenticated(self, db_session: AsyncSession) -> None:
751 """Empty owner_user_id (unauthenticated caller) must be rejected."""
752 result = await execute_create_repo(
753 name="hacked-repo",
754 owner="",
755 owner_user_id="",
756 )
757 assert result.ok is False
758 assert result.error_code == "forbidden"
759
760
761 class TestIntegrationCreateLabel:
762 async def test_happy_path(self, db_session: AsyncSession) -> None:
763 r = await _repo(db_session)
764 await db_session.commit()
765 result = await execute_create_label(
766 repo_id=r.repo_id, name="bug", color="#d73a4a", actor="alice"
767 )
768 assert result.ok is True
769 assert "label_id" in result.data
770 assert result.data["name"] == "bug"
771 assert result.data["color"] == "#d73a4a"
772
773 async def test_duplicate_name_rejected(self, db_session: AsyncSession) -> None:
774 r = await _repo(db_session)
775 await db_session.commit()
776 await execute_create_label(repo_id=r.repo_id, name="dup-label", color="#aabbcc", actor="alice")
777 result = await execute_create_label(
778 repo_id=r.repo_id, name="dup-label", color="#112233", actor="alice"
779 )
780 assert result.ok is False
781 assert "already exists" in (result.error_message or "").lower()
782
783 async def test_invalid_color_rejected(self, db_session: AsyncSession) -> None:
784 r = await _repo(db_session)
785 await db_session.commit()
786 result = await execute_create_label(
787 repo_id=r.repo_id, name="bad-color", color="d73a4a" # missing #
788 )
789 assert result.ok is False
790 assert result.error_code == "invalid_args"
791
792 async def test_empty_name_rejected(self, db_session: AsyncSession) -> None:
793 r = await _repo(db_session)
794 await db_session.commit()
795 result = await execute_create_label(repo_id=r.repo_id, name=" ", color="#d73a4a")
796 assert result.ok is False
797 assert result.error_code == "invalid_args"
798
799 async def test_unknown_repo_returns_error(self, db_session: AsyncSession) -> None:
800 result = await execute_create_label(
801 repo_id="00000000-0000-0000-0000-000000000000",
802 name="bug",
803 color="#d73a4a",
804 )
805 assert result.ok is False
806 assert result.error_code == "repo_not_found"
807
808
809 class TestIntegrationListLabels:
810 async def test_returns_all_labels(self, db_session: AsyncSession) -> None:
811 r = await _repo(db_session)
812 await db_session.commit()
813 await execute_create_label(repo_id=r.repo_id, name="bug", color="#d73a4a", actor="alice")
814 await execute_create_label(repo_id=r.repo_id, name="enhancement", color="#a2eeef", actor="alice")
815 result = await execute_list_labels(r.repo_id)
816 assert result.ok is True
817 assert result.data["total"] == 2
818 names = {lbl["name"] for lbl in result.data["labels"]}
819 assert names == {"bug", "enhancement"}
820
821 async def test_empty_repo_returns_empty_list(self, db_session: AsyncSession) -> None:
822 r = await _repo(db_session)
823 await db_session.commit()
824 result = await execute_list_labels(r.repo_id)
825 assert result.ok is True
826 assert result.data["total"] == 0
827 assert result.data["labels"] == []
828
829 async def test_unknown_repo_returns_error(self, db_session: AsyncSession) -> None:
830 result = await execute_list_labels("00000000-0000-0000-0000-000000000000")
831 assert result.ok is False
832 assert result.error_code == "repo_not_found"
833
834
835 class TestIntegrationUpdateLabel:
836 async def test_rename_label(self, db_session: AsyncSession) -> None:
837 r = await _repo(db_session)
838 await db_session.commit()
839 created = await execute_create_label(repo_id=r.repo_id, name="old-name", color="#d73a4a", actor="alice")
840 label_id: str = created.data["label_id"]
841 result = await execute_update_label(
842 repo_id=r.repo_id, label_id=label_id, name="new-name", actor="alice"
843 )
844 assert result.ok is True
845 assert result.data["name"] == "new-name"
846 assert result.data["color"] == "#d73a4a" # unchanged
847
848 async def test_change_color(self, db_session: AsyncSession) -> None:
849 r = await _repo(db_session)
850 await db_session.commit()
851 created = await execute_create_label(repo_id=r.repo_id, name="bug", color="#d73a4a", actor="alice")
852 label_id: str = created.data["label_id"]
853 result = await execute_update_label(
854 repo_id=r.repo_id, label_id=label_id, color="#b60205", actor="alice"
855 )
856 assert result.ok is True
857 assert result.data["color"] == "#b60205"
858 assert result.data["name"] == "bug" # unchanged
859
860 async def test_no_fields_returns_error(self, db_session: AsyncSession) -> None:
861 r = await _repo(db_session)
862 await db_session.commit()
863 created = await execute_create_label(repo_id=r.repo_id, name="bug", color="#d73a4a", actor="alice")
864 result = await execute_update_label(
865 repo_id=r.repo_id, label_id=created.data["label_id"]
866 )
867 assert result.ok is False
868 assert result.error_code == "invalid_args"
869
870 async def test_rename_conflict_rejected(self, db_session: AsyncSession) -> None:
871 r = await _repo(db_session)
872 await db_session.commit()
873 created = await execute_create_label(repo_id=r.repo_id, name="bug", color="#d73a4a", actor="alice")
874 await execute_create_label(repo_id=r.repo_id, name="enhancement", color="#a2eeef", actor="alice")
875 result = await execute_update_label(
876 repo_id=r.repo_id, label_id=created.data["label_id"], name="enhancement", actor="alice"
877 )
878 assert result.ok is False
879 assert result.error_code == "already_exists"
880
881 async def test_not_found_returns_error(self, db_session: AsyncSession) -> None:
882 r = await _repo(db_session)
883 await db_session.commit()
884 result = await execute_update_label(
885 repo_id=r.repo_id,
886 label_id="00000000-0000-0000-0000-000000000000",
887 name="new-name",
888 actor="alice",
889 )
890 assert result.ok is False
891 assert result.error_code == "not_found"
892
893 async def test_invalid_color_rejected(self, db_session: AsyncSession) -> None:
894 r = await _repo(db_session)
895 await db_session.commit()
896 created = await execute_create_label(repo_id=r.repo_id, name="bug", color="#d73a4a", actor="alice")
897 result = await execute_update_label(
898 repo_id=r.repo_id, label_id=created.data["label_id"], color="b60205" # missing #
899 )
900 assert result.ok is False
901 assert result.error_code == "invalid_args"
902
903
904 class TestIntegrationDeleteLabel:
905 async def test_deletes_label(self, db_session: AsyncSession) -> None:
906 r = await _repo(db_session)
907 await db_session.commit()
908 created = await execute_create_label(repo_id=r.repo_id, name="bug", color="#d73a4a", actor="alice")
909 label_id: str = created.data["label_id"]
910 result = await execute_delete_label(repo_id=r.repo_id, label_id=label_id, actor="alice")
911 assert result.ok is True
912 assert result.data["deleted"] is True
913 assert result.data["name"] == "bug"
914 # Confirm it no longer exists.
915 listed = await execute_list_labels(r.repo_id)
916 assert listed.data["total"] == 0
917
918 async def test_not_found_returns_error(self, db_session: AsyncSession) -> None:
919 r = await _repo(db_session)
920 await db_session.commit()
921 result = await execute_delete_label(
922 repo_id=r.repo_id,
923 label_id="00000000-0000-0000-0000-000000000000",
924 actor="alice",
925 )
926 assert result.ok is False
927 assert result.error_code == "not_found"
928
929
930 class TestLabelWriteAccessGuard:
931 """Non-owners must be rejected when managing labels."""
932
933 async def test_create_label_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
934 r = await _repo(db_session)
935 await db_session.commit()
936 result = await execute_create_label(
937 repo_id=r.repo_id, name="bug", color="#d73a4a", actor="eve"
938 )
939 assert result.ok is False
940 assert result.error_code == "forbidden"
941
942 async def test_update_label_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
943 r = await _repo(db_session)
944 await db_session.commit()
945 created = await execute_create_label(repo_id=r.repo_id, name="bug", color="#d73a4a", actor="alice")
946 result = await execute_update_label(
947 repo_id=r.repo_id, label_id=created.data["label_id"], name="hacked", actor="eve"
948 )
949 assert result.ok is False
950 assert result.error_code == "forbidden"
951
952 async def test_delete_label_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
953 r = await _repo(db_session)
954 await db_session.commit()
955 created = await execute_create_label(repo_id=r.repo_id, name="bug", color="#d73a4a", actor="alice")
956 result = await execute_delete_label(
957 repo_id=r.repo_id, label_id=created.data["label_id"], actor="eve"
958 )
959 assert result.ok is False
960 assert result.error_code == "forbidden"
961
962 async def test_create_label_forbidden_when_unauthenticated(self, db_session: AsyncSession) -> None:
963 r = await _repo(db_session)
964 await db_session.commit()
965 result = await execute_create_label(repo_id=r.repo_id, name="bug", color="#d73a4a")
966 assert result.ok is False
967 assert result.error_code == "forbidden"
968
969
970 # ── Layer 3 — End-to-End ──────────────────────────────────────────────────────
971
972
973 class TestE2EAuthGate:
974 """Every write tool call without auth must return 401."""
975
976 async def test_create_issue_no_auth(
977 self, http_client: AsyncClient, db_session: AsyncSession
978 ) -> None:
979 r = await _repo(db_session)
980 resp = await http_client.post(
981 "/mcp",
982 json=_tools_call("musehub_create_issue", {"repo_id": r.repo_id, "title": "T"}),
983 headers={"Content-Type": "application/json"},
984 )
985 assert resp.status_code == 401
986
987 async def test_create_repo_no_auth(
988 self, http_client: AsyncClient, db_session: AsyncSession
989 ) -> None:
990 resp = await http_client.post(
991 "/mcp",
992 json=_tools_call("musehub_create_repo", {"name": "test-repo"}),
993 headers={"Content-Type": "application/json"},
994 )
995 assert resp.status_code == 401
996
997 async def test_create_proposal_no_auth(
998 self, http_client: AsyncClient, db_session: AsyncSession
999 ) -> None:
1000 resp = await http_client.post(
1001 "/mcp",
1002 json=_tools_call("musehub_create_proposal", {
1003 "repo_id": "x", "title": "T", "from_branch": "a", "to_branch": "b"
1004 }),
1005 headers={"Content-Type": "application/json"},
1006 )
1007 assert resp.status_code == 401
1008
1009 async def test_merge_proposal_no_auth(
1010 self, http_client: AsyncClient, db_session: AsyncSession
1011 ) -> None:
1012 resp = await http_client.post(
1013 "/mcp",
1014 json=_tools_call("musehub_merge_proposal", {"repo_id": "x", "proposal_id": "y"}),
1015 headers={"Content-Type": "application/json"},
1016 )
1017 assert resp.status_code == 401
1018
1019 async def test_create_release_no_auth(
1020 self, http_client: AsyncClient, db_session: AsyncSession
1021 ) -> None:
1022 resp = await http_client.post(
1023 "/mcp",
1024 json=_tools_call("musehub_create_release", {"repo_id": "x", "tag": "v1.0", "title": "R"}),
1025 headers={"Content-Type": "application/json"},
1026 )
1027 assert resp.status_code == 401
1028
1029
1030
1031 class TestE2EAuthenticatedWrite:
1032 """Write tools succeed when auth is present."""
1033
1034 async def test_create_issue_with_auth(
1035 self, http_client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
1036 ) -> None:
1037 r = await _repo(db_session)
1038 await db_session.commit()
1039
1040 resp = await http_client.post(
1041 "/mcp",
1042 json=_tools_call("musehub_create_issue", {
1043 "repo_id": r.repo_id, "title": "Auth issue"
1044 }),
1045 headers=auth_headers,
1046 )
1047 assert resp.status_code == 200
1048 result = resp.json()["result"]
1049 assert result["isError"] is False
1050 payload = json.loads(_unwrap_tool_text(result["content"][0]["text"]))
1051 assert payload["title"] == "Auth issue"
1052
1053 async def test_create_repo_with_auth(
1054 self, http_client: AsyncClient, db_session: AsyncSession, auth_headers: StrDict
1055 ) -> None:
1056 resp = await http_client.post(
1057 "/mcp",
1058 json=_tools_call("musehub_create_repo", {
1059 "name": "e2e-authed-repo",
1060 "owner": "testuser",
1061 }),
1062 headers=auth_headers,
1063 )
1064 assert resp.status_code == 200
1065 result = resp.json()["result"]
1066 assert result["isError"] is False
1067 payload = json.loads(_unwrap_tool_text(result["content"][0]["text"]))
1068 assert "repo_id" in payload
1069
1070
1071 # ── Layer 4 — Stress ──────────────────────────────────────────────────────────
1072
1073
1074 class TestStressWriteTools:
1075 async def test_20_sequential_issues_have_sequential_numbers(
1076 self, db_session: AsyncSession
1077 ) -> None:
1078 r = await _repo(db_session)
1079 await db_session.commit()
1080 numbers = []
1081 for i in range(20):
1082 result = await execute_create_issue(
1083 repo_id=r.repo_id, title=f"Issue {i}", actor="alice"
1084 )
1085 assert result.ok is True
1086 numbers.append(result.data["number"])
1087 assert numbers == list(range(1, 21))
1088
1089
1090
1091 # ── Layer 5 — Data Integrity ──────────────────────────────────────────────────
1092
1093
1094 class TestDataIntegrityIssue:
1095 async def test_created_issue_retrievable(self, db_session: AsyncSession) -> None:
1096 r = await _repo(db_session)
1097 await db_session.commit()
1098 await execute_create_issue(
1099 repo_id=r.repo_id, title="Retrievable issue", actor="alice"
1100 )
1101 issues_result = await execute_list_issues(r.repo_id, state="open")
1102 assert issues_result.ok is True
1103 titles = [i["title"] for i in issues_result.data["issues"]]
1104 assert "Retrievable issue" in titles
1105
1106 async def test_closed_issue_state_persisted(self, db_session: AsyncSession) -> None:
1107 r = await _repo(db_session)
1108 await db_session.commit()
1109 created = await execute_create_issue(
1110 repo_id=r.repo_id, title="Close me", actor="alice"
1111 )
1112 num = created.data["number"]
1113
1114 await execute_update_issue(repo_id=r.repo_id, issue_number=num, state="closed", actor="alice")
1115
1116 issues_result = await execute_list_issues(r.repo_id, state="closed")
1117 assert issues_result.ok is True
1118 numbers = [i["number"] for i in issues_result.data["issues"]]
1119 assert num in numbers
1120
1121
1122 class TestDataIntegrityProposal:
1123 async def test_created_proposal_retrievable(self, db_session: AsyncSession) -> None:
1124 r = await _repo(db_session)
1125 await _commit_and_branch(db_session, r.repo_id, "main")
1126 await _commit_and_branch(db_session, r.repo_id, "feat-di")
1127 await db_session.commit()
1128
1129 proposal_result = await execute_create_proposal(
1130 repo_id=r.repo_id,
1131 title="DI proposal",
1132 from_branch="feat-di",
1133 to_branch="main",
1134 actor="alice",
1135 )
1136 assert proposal_result.ok is True
1137
1138 list_result = await execute_list_proposals(r.repo_id, state="open")
1139 assert list_result.ok is True
1140 titles = [p["title"] for p in list_result.data["pulls"]]
1141 assert "DI proposal" in titles
1142
1143
1144 class TestDataIntegrityRelease:
1145 async def test_release_tag_persisted(self, db_session: AsyncSession) -> None:
1146 r = await _repo(db_session)
1147 await db_session.commit()
1148 result = await execute_create_release(
1149 repo_id=r.repo_id, tag="v3.7.2", title="Minor release", actor="alice"
1150 )
1151 assert result.ok is True
1152 assert result.data["tag"] == "v3.7.2"
1153
1154 async def test_release_author_persisted(self, db_session: AsyncSession) -> None:
1155 r = await _repo(db_session)
1156 await db_session.commit()
1157 result = await execute_create_release(
1158 repo_id=r.repo_id, tag="v0.1.0", actor="alice"
1159 )
1160 assert result.ok is True
1161 assert result.data["author"] == "alice"
1162
1163
1164 # ── Layer 6 — Security ────────────────────────────────────────────────────────
1165
1166
1167 class TestSecurityWriteTools:
1168 def test_all_write_tools_in_auth_gate_set(self) -> None:
1169 """All MUSEHUB_WRITE_TOOLS must be in MUSEHUB_WRITE_TOOL_NAMES."""
1170 from musehub.mcp.tools.musehub import MUSEHUB_WRITE_TOOLS
1171 write_tool_names_from_list = {t["name"] for t in MUSEHUB_WRITE_TOOLS}
1172 # All write tools in the list should be in the gate set.
1173 uncovered = write_tool_names_from_list - MUSEHUB_WRITE_TOOL_NAMES
1174 assert not uncovered, f"Write tools not in auth gate: {uncovered}"
1175
1176 async def test_create_proposal_same_branch_guard(self, db_session: AsyncSession) -> None:
1177 r = await _repo(db_session)
1178 await db_session.commit()
1179 result = await execute_create_proposal(
1180 repo_id=r.repo_id, title="T", from_branch="main", to_branch="main", actor="alice"
1181 )
1182 assert result.ok is False
1183
1184 async def test_duplicate_label_rejected(self, db_session: AsyncSession) -> None:
1185 r = await _repo(db_session)
1186 await db_session.commit()
1187 await execute_create_label(repo_id=r.repo_id, name="sec-label", color="aabbcc")
1188 result = await execute_create_label(
1189 repo_id=r.repo_id, name="sec-label", color="ddeeff"
1190 )
1191 assert result.ok is False
1192
1193 async def test_create_issue_comment_on_wrong_issue_safe(
1194 self, db_session: AsyncSession
1195 ) -> None:
1196 """create_issue_comment on non-existent issue must return not_found, not crash."""
1197 r = await _repo(db_session)
1198 await db_session.commit()
1199 result = await execute_create_issue_comment(
1200 repo_id=r.repo_id, issue_number=9999, body="hack", actor="eve"
1201 )
1202 assert result.ok is False
1203 assert result.error_code == "issue_not_found"
1204
1205
1206 # ── Layer 7 — Performance ─────────────────────────────────────────────────────
1207
1208
1209 class TestPerformanceWriteTools:
1210 async def test_10_sequential_issues_under_500ms(
1211 self, db_session: AsyncSession
1212 ) -> None:
1213 r = await _repo(db_session)
1214 await db_session.commit()
1215 start = time.perf_counter()
1216 for i in range(10):
1217 result = await execute_create_issue(
1218 repo_id=r.repo_id, title=f"Perf issue {i}", actor="alice"
1219 )
1220 assert result.ok is True
1221 elapsed_ms = (time.perf_counter() - start) * 1000
1222 assert elapsed_ms < 2000, f"10 issues took {elapsed_ms:.1f} ms"
1223
1224 async def test_5_sequential_repos_under_1000ms(
1225 self, db_session: AsyncSession
1226 ) -> None:
1227 start = time.perf_counter()
1228 for i in range(5):
1229 result = await execute_create_repo(
1230 name=f"perf-repo-{i}",
1231 owner="alice",
1232 owner_user_id=compute_identity_id(b"alice"),
1233 initialize=False,
1234 )
1235 assert result.ok is True
1236 elapsed_ms = (time.perf_counter() - start) * 1000
1237 assert elapsed_ms < 1000, f"5 repos took {elapsed_ms:.1f} ms"
1238
1239
1240 # ── Issue state transition tests ───────────────────────────────────────────────
1241
1242
1243 class TestIntegrationCloseIssue:
1244 async def test_close_open_issue(self, db_session: AsyncSession) -> None:
1245 r = await _repo(db_session)
1246 await db_session.commit()
1247 created = await execute_create_issue(repo_id=r.repo_id, title="Bug to close", actor="alice")
1248 num: int = created.data["number"]
1249
1250 result = await execute_close_issue(repo_id=r.repo_id, issue_number=num, actor="alice")
1251 assert result.ok is True
1252 assert result.data["state"] == "closed"
1253 assert result.data["number"] == num
1254
1255 async def test_close_already_closed_is_idempotent(self, db_session: AsyncSession) -> None:
1256 r = await _repo(db_session)
1257 await db_session.commit()
1258 created = await execute_create_issue(repo_id=r.repo_id, title="Double close", actor="alice")
1259 num: int = created.data["number"]
1260
1261 await execute_close_issue(repo_id=r.repo_id, issue_number=num, actor="alice")
1262 result = await execute_close_issue(repo_id=r.repo_id, issue_number=num, actor="alice")
1263 assert result.ok is True
1264 assert result.data["state"] == "closed"
1265
1266 async def test_close_unknown_issue_returns_error(self, db_session: AsyncSession) -> None:
1267 r = await _repo(db_session)
1268 await db_session.commit()
1269 result = await execute_close_issue(repo_id=r.repo_id, issue_number=9999, actor="alice")
1270 assert result.ok is False
1271 assert result.error_code == "issue_not_found"
1272
1273 async def test_close_unknown_repo_returns_error(self, db_session: AsyncSession) -> None:
1274 nonexistent_repo_id = compute_repo_id(compute_identity_id(b"ghost"), "ghost-repo", "code", "2025-01-01T00:00:00")
1275 result = await execute_close_issue(repo_id=nonexistent_repo_id, issue_number=1, actor="alice")
1276 assert result.ok is False
1277 # close_issue returns None for unknown repo (issue not found path)
1278 assert result.error_code in ("issue_not_found", "repo_not_found", "invalid_args")
1279
1280
1281 class TestIntegrationReopenIssue:
1282 async def test_reopen_closed_issue(self, db_session: AsyncSession) -> None:
1283 r = await _repo(db_session)
1284 await db_session.commit()
1285 created = await execute_create_issue(repo_id=r.repo_id, title="Reopen me", actor="alice")
1286 num: int = created.data["number"]
1287 await execute_close_issue(repo_id=r.repo_id, issue_number=num, actor="alice")
1288
1289 result = await execute_reopen_issue(repo_id=r.repo_id, issue_number=num, actor="alice")
1290 assert result.ok is True
1291 assert result.data["state"] == "open"
1292 assert result.data["number"] == num
1293
1294 async def test_reopen_already_open_is_idempotent(self, db_session: AsyncSession) -> None:
1295 r = await _repo(db_session)
1296 await db_session.commit()
1297 created = await execute_create_issue(repo_id=r.repo_id, title="Already open", actor="alice")
1298 num: int = created.data["number"]
1299
1300 result = await execute_reopen_issue(repo_id=r.repo_id, issue_number=num, actor="alice")
1301 assert result.ok is True
1302 assert result.data["state"] == "open"
1303
1304 async def test_reopen_unknown_issue_returns_error(self, db_session: AsyncSession) -> None:
1305 r = await _repo(db_session)
1306 await db_session.commit()
1307 result = await execute_reopen_issue(repo_id=r.repo_id, issue_number=9999, actor="alice")
1308 assert result.ok is False
1309 assert result.error_code == "issue_not_found"
1310
1311
1312 class TestIntegrationAssignIssue:
1313 async def test_assign_sets_assignee(self, db_session: AsyncSession) -> None:
1314 r = await _repo(db_session)
1315 await db_session.commit()
1316 created = await execute_create_issue(repo_id=r.repo_id, title="Assignable", actor="alice")
1317 num: int = created.data["number"]
1318
1319 result = await execute_assign_issue(
1320 repo_id=r.repo_id, issue_number=num, assignee="bob", actor="alice"
1321 )
1322 assert result.ok is True
1323 assert result.data["assignee"] == "bob"
1324
1325 async def test_unassign_with_empty_string(self, db_session: AsyncSession) -> None:
1326 r = await _repo(db_session)
1327 await db_session.commit()
1328 created = await execute_create_issue(repo_id=r.repo_id, title="Unassignable", actor="alice")
1329 num: int = created.data["number"]
1330 await execute_assign_issue(repo_id=r.repo_id, issue_number=num, assignee="bob", actor="alice")
1331
1332 result = await execute_assign_issue(
1333 repo_id=r.repo_id, issue_number=num, assignee="", actor="alice"
1334 )
1335 assert result.ok is True
1336 assert result.data["assignee"] is None or result.data["assignee"] == ""
1337
1338 async def test_assign_unknown_issue_returns_error(self, db_session: AsyncSession) -> None:
1339 r = await _repo(db_session)
1340 await db_session.commit()
1341 result = await execute_assign_issue(
1342 repo_id=r.repo_id, issue_number=9999, assignee="bob", actor="alice"
1343 )
1344 assert result.ok is False
1345 assert result.error_code == "issue_not_found"
1346
1347
1348 class TestIntegrationSetIssueLabels:
1349 async def test_set_labels_replaces_all(self, db_session: AsyncSession) -> None:
1350 r = await _repo(db_session)
1351 await db_session.commit()
1352 created = await execute_create_issue(
1353 repo_id=r.repo_id, title="Label me", labels=["bug"], actor="alice"
1354 )
1355 num: int = created.data["number"]
1356
1357 result = await execute_update_issue_labels(
1358 repo_id=r.repo_id, issue_number=num, labels=["enhancement", "help-wanted"], actor="alice"
1359 )
1360 assert result.ok is True
1361 assert set(result.data["labels"]) == {"enhancement", "help-wanted"}
1362
1363 async def test_set_empty_labels_clears_all(self, db_session: AsyncSession) -> None:
1364 r = await _repo(db_session)
1365 await db_session.commit()
1366 created = await execute_create_issue(
1367 repo_id=r.repo_id, title="Clear labels", labels=["bug"], actor="alice"
1368 )
1369 num: int = created.data["number"]
1370
1371 result = await execute_update_issue_labels(
1372 repo_id=r.repo_id, issue_number=num, labels=[], actor="alice"
1373 )
1374 assert result.ok is True
1375 assert result.data["labels"] == []
1376
1377 async def test_set_labels_unknown_issue_returns_error(self, db_session: AsyncSession) -> None:
1378 r = await _repo(db_session)
1379 await db_session.commit()
1380 result = await execute_update_issue_labels(
1381 repo_id=r.repo_id, issue_number=9999, labels=["bug"], actor="alice"
1382 )
1383 assert result.ok is False
1384 assert result.error_code == "issue_not_found"
1385
1386
1387 class TestIntegrationRemoveIssueLabel:
1388 async def test_remove_existing_label(self, db_session: AsyncSession) -> None:
1389 r = await _repo(db_session)
1390 await db_session.commit()
1391 created = await execute_create_issue(
1392 repo_id=r.repo_id, title="Multi-label", labels=["bug", "enhancement"], actor="alice"
1393 )
1394 num: int = created.data["number"]
1395
1396 result = await execute_remove_issue_label(
1397 repo_id=r.repo_id, issue_number=num, label="bug", actor="alice"
1398 )
1399 assert result.ok is True
1400 assert "bug" not in result.data["labels"]
1401 assert "enhancement" in result.data["labels"]
1402
1403 async def test_remove_absent_label_is_idempotent(self, db_session: AsyncSession) -> None:
1404 r = await _repo(db_session)
1405 await db_session.commit()
1406 created = await execute_create_issue(
1407 repo_id=r.repo_id, title="No label", labels=[], actor="alice"
1408 )
1409 num: int = created.data["number"]
1410
1411 result = await execute_remove_issue_label(
1412 repo_id=r.repo_id, issue_number=num, label="nonexistent", actor="alice"
1413 )
1414 assert result.ok is True
1415 assert result.data["labels"] == []
1416
1417 async def test_remove_label_unknown_issue_returns_error(self, db_session: AsyncSession) -> None:
1418 r = await _repo(db_session)
1419 await db_session.commit()
1420 result = await execute_remove_issue_label(
1421 repo_id=r.repo_id, issue_number=9999, label="bug", actor="alice"
1422 )
1423 assert result.ok is False
1424 assert result.error_code == "issue_not_found"
1425
1426
1427 class TestDataIntegrityIssueStateTransitions:
1428 async def test_close_then_reopen_cycle(self, db_session: AsyncSession) -> None:
1429 r = await _repo(db_session)
1430 await db_session.commit()
1431 created = await execute_create_issue(repo_id=r.repo_id, title="Cycle issue", actor="alice")
1432 num: int = created.data["number"]
1433
1434 await execute_close_issue(repo_id=r.repo_id, issue_number=num, actor="alice")
1435 closed_result = await execute_list_issues(r.repo_id, state="closed")
1436 assert num in [i["number"] for i in closed_result.data["issues"]]
1437
1438 await execute_reopen_issue(repo_id=r.repo_id, issue_number=num, actor="alice")
1439 open_result = await execute_list_issues(r.repo_id, state="open")
1440 assert num in [i["number"] for i in open_result.data["issues"]]
1441
1442 async def test_assign_then_unassign_persisted(self, db_session: AsyncSession) -> None:
1443 r = await _repo(db_session)
1444 await db_session.commit()
1445 created = await execute_create_issue(repo_id=r.repo_id, title="Assign cycle", actor="alice")
1446 num: int = created.data["number"]
1447
1448 await execute_assign_issue(repo_id=r.repo_id, issue_number=num, assignee="carol", actor="alice")
1449 await execute_assign_issue(repo_id=r.repo_id, issue_number=num, assignee="", actor="alice")
1450
1451 open_list = await execute_list_issues(r.repo_id, state="open")
1452 issue = next((i for i in open_list.data["issues"] if i["number"] == num), None)
1453 assert issue is not None
1454 assert issue.get("assignee") is None or issue.get("assignee") == ""
1455
1456
1457 class TestIntegrationDeleteIssueComment:
1458 """Tests for execute_delete_issue_comment."""
1459
1460 async def test_delete_existing_comment(self, db_session: AsyncSession) -> None:
1461 """Deleting an existing comment returns ok=True and deleted=True."""
1462 from musehub.mcp.write_tools.issues import execute_create_issue_comment
1463
1464 r = await _repo(db_session)
1465 await db_session.commit()
1466
1467 issue_result = await execute_create_issue(repo_id=r.repo_id, title="Issue with comment", actor="alice")
1468 num: int = issue_result.data["number"]
1469
1470 comment_result = await execute_create_issue_comment(
1471 repo_id=r.repo_id, issue_number=num, body="A comment", actor="alice"
1472 )
1473 assert comment_result.ok is True
1474 comment_id: str = comment_result.data["comment_id"]
1475
1476 delete_result = await execute_delete_issue_comment(
1477 repo_id=r.repo_id, issue_number=num, comment_id=comment_id, actor="alice"
1478 )
1479 assert delete_result.ok is True
1480 assert delete_result.data["deleted"] is True
1481 assert delete_result.data["comment_id"] == comment_id
1482
1483 async def test_delete_unknown_comment_returns_error(self, db_session: AsyncSession) -> None:
1484 """Deleting a non-existent comment returns ok=False with comment_not_found."""
1485 r = await _repo(db_session)
1486 await db_session.commit()
1487
1488 issue_result = await execute_create_issue(repo_id=r.repo_id, title="Commentless issue", actor="alice")
1489 num: int = issue_result.data["number"]
1490
1491 result = await execute_delete_issue_comment(
1492 repo_id=r.repo_id, issue_number=num, comment_id="ghost-comment-id", actor="alice"
1493 )
1494 assert result.ok is False
1495 assert result.error_code == "comment_not_found"
1496
1497 async def test_delete_comment_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
1498 """Non-owner cannot delete a comment — returns forbidden."""
1499 from musehub.mcp.write_tools.issues import execute_create_issue_comment
1500
1501 r = await _repo(db_session, owner="alice")
1502 await db_session.commit()
1503
1504 issue_result = await execute_create_issue(repo_id=r.repo_id, title="Protected issue", actor="alice")
1505 num: int = issue_result.data["number"]
1506
1507 comment_result = await execute_create_issue_comment(
1508 repo_id=r.repo_id, issue_number=num, body="Comment to protect", actor="alice"
1509 )
1510 comment_id: str = comment_result.data["comment_id"]
1511
1512 result = await execute_delete_issue_comment(
1513 repo_id=r.repo_id, issue_number=num, comment_id=comment_id, actor="carol"
1514 )
1515 assert result.ok is False
1516 assert result.error_code == "forbidden"
1517
1518
1519 class TestIntegrationCollaborators:
1520 """Tests for execute_list/invite/update/remove_collaborator."""
1521
1522 async def test_invite_and_list_collaborator(self, db_session: AsyncSession) -> None:
1523 """Inviting a collaborator then listing shows the new entry."""
1524 r = await _repo(db_session, owner="alice")
1525 await db_session.commit()
1526
1527 invite = await execute_invite_collaborator(
1528 repo_id=r.repo_id, handle="carol", permission="write", actor="alice"
1529 )
1530 assert invite.ok is True
1531 assert invite.data["handle"] == "carol"
1532 assert invite.data["permission"] == "write"
1533
1534 listing = await execute_list_collaborators(repo_id=r.repo_id, actor="alice")
1535 assert listing.ok is True
1536 handles = [c["handle"] for c in listing.data["collaborators"]]
1537 assert "carol" in handles
1538
1539 async def test_invite_duplicate_returns_conflict(self, db_session: AsyncSession) -> None:
1540 """Inviting the same handle twice returns error_code='conflict'."""
1541 r = await _repo(db_session, owner="alice")
1542 await db_session.commit()
1543
1544 await execute_invite_collaborator(repo_id=r.repo_id, handle="carol", actor="alice")
1545 result = await execute_invite_collaborator(repo_id=r.repo_id, handle="carol", actor="alice")
1546 assert result.ok is False
1547 assert result.error_code == "conflict"
1548
1549 async def test_update_collaborator_permission(self, db_session: AsyncSession) -> None:
1550 """Updating a collaborator's permission is reflected immediately."""
1551 r = await _repo(db_session, owner="alice")
1552 await db_session.commit()
1553
1554 await execute_invite_collaborator(repo_id=r.repo_id, handle="carol", permission="read", actor="alice")
1555 update = await execute_update_collaborator_permission(
1556 repo_id=r.repo_id, handle="carol", permission="admin", actor="alice"
1557 )
1558 assert update.ok is True
1559 assert update.data["permission"] == "admin"
1560
1561 async def test_remove_collaborator(self, db_session: AsyncSession) -> None:
1562 """Removing a collaborator causes them to disappear from the list."""
1563 r = await _repo(db_session, owner="alice")
1564 await db_session.commit()
1565
1566 await execute_invite_collaborator(repo_id=r.repo_id, handle="carol", actor="alice")
1567 remove = await execute_remove_collaborator(repo_id=r.repo_id, handle="carol", actor="alice")
1568 assert remove.ok is True
1569 assert remove.data["removed"] is True
1570
1571 listing = await execute_list_collaborators(repo_id=r.repo_id, actor="alice")
1572 handles = [c["handle"] for c in listing.data["collaborators"]]
1573 assert "carol" not in handles
1574
1575 async def test_invite_forbidden_for_non_admin(self, db_session: AsyncSession) -> None:
1576 """A non-admin collaborator cannot invite others."""
1577 r = await _repo(db_session, owner="alice")
1578 await db_session.commit()
1579
1580 result = await execute_invite_collaborator(
1581 repo_id=r.repo_id, handle="dave", permission="write", actor="carol"
1582 )
1583 assert result.ok is False
1584 assert result.error_code == "forbidden"
1585
1586 async def test_accept_invite_sets_accepted_true_and_unblocks_admin_actions(self, db_session: AsyncSession) -> None:
1587 """musehub#193: accepting an invite is what makes a granted permission usable."""
1588 r = await _repo(db_session, owner="alice")
1589 await db_session.commit()
1590
1591 await execute_invite_collaborator(repo_id=r.repo_id, handle="carol", permission="admin", actor="alice")
1592
1593 # Before accepting: carol's admin invite is not yet usable.
1594 pre = await execute_invite_collaborator(repo_id=r.repo_id, handle="dave", actor="carol")
1595 assert pre.ok is False
1596 assert pre.error_code == "forbidden"
1597
1598 accept = await execute_accept_collaborator_invite(repo_id=r.repo_id, actor="carol")
1599 assert accept.ok is True
1600 assert accept.data["accepted"] is True
1601
1602 # After accepting: the same admin invite now works.
1603 post = await execute_invite_collaborator(repo_id=r.repo_id, handle="dave", actor="carol")
1604 assert post.ok is True
1605
1606 async def test_accept_invite_is_idempotent(self, db_session: AsyncSession) -> None:
1607 r = await _repo(db_session, owner="alice")
1608 await db_session.commit()
1609 await execute_invite_collaborator(repo_id=r.repo_id, handle="carol", actor="alice")
1610
1611 first = await execute_accept_collaborator_invite(repo_id=r.repo_id, actor="carol")
1612 second = await execute_accept_collaborator_invite(repo_id=r.repo_id, actor="carol")
1613 assert first.ok is True
1614 assert second.ok is True
1615 assert second.data["accepted"] is True
1616
1617 async def test_accept_invite_no_pending_invite(self, db_session: AsyncSession) -> None:
1618 r = await _repo(db_session, owner="alice")
1619 await db_session.commit()
1620
1621 result = await execute_accept_collaborator_invite(repo_id=r.repo_id, actor="carol")
1622 assert result.ok is False
1623 assert result.error_code == "collaborator_not_found"
1624
1625 async def test_accept_invite_requires_auth(self, db_session: AsyncSession) -> None:
1626 r = await _repo(db_session, owner="alice")
1627 await db_session.commit()
1628
1629 result = await execute_accept_collaborator_invite(repo_id=r.repo_id, actor="")
1630 assert result.ok is False
1631 assert result.error_code == "forbidden"
1632
1633 async def test_accept_invite_unknown_repo(self, db_session: AsyncSession) -> None:
1634 result = await execute_accept_collaborator_invite(repo_id=fake_id("ghost-repo"), actor="carol")
1635 assert result.ok is False
1636 assert result.error_code == "repo_not_found"
1637
1638 async def test_list_collaborators_unknown_repo(self, db_session: AsyncSession) -> None:
1639 """Listing collaborators for a non-existent repo returns repo_not_found."""
1640 result = await execute_list_collaborators(repo_id=fake_id("ghost-repo"), actor="alice")
1641 assert result.ok is False
1642 assert result.error_code == "repo_not_found"
1643
1644
1645 class TestIntegrationWebhooksMCP:
1646 """Tests for execute_create/list/delete_webhook via MCP layer."""
1647
1648 async def test_create_and_list_webhook(self, db_session: AsyncSession) -> None:
1649 """Creating a webhook then listing shows the new subscription."""
1650 r = await _repo(db_session, owner="alice")
1651 await db_session.commit()
1652
1653 created = await execute_create_webhook(
1654 repo_id=r.repo_id,
1655 url="https://example.com/hook",
1656 events=["push", "issue"],
1657 actor="alice",
1658 )
1659 assert created.ok is True
1660 assert created.data["url"] == "https://example.com/hook"
1661 assert set(created.data["events"]) == {"push", "issue"}
1662 webhook_id: str = created.data["webhook_id"]
1663
1664 listing = await execute_list_webhooks(repo_id=r.repo_id, actor="alice")
1665 assert listing.ok is True
1666 ids = [w["webhook_id"] for w in listing.data["webhooks"]]
1667 assert webhook_id in ids
1668
1669 async def test_delete_webhook(self, db_session: AsyncSession) -> None:
1670 """Deleting a webhook removes it from the listing."""
1671 r = await _repo(db_session, owner="alice")
1672 await db_session.commit()
1673
1674 created = await execute_create_webhook(
1675 repo_id=r.repo_id,
1676 url="https://example.com/delete-hook",
1677 events=["push"],
1678 actor="alice",
1679 )
1680 webhook_id: str = created.data["webhook_id"]
1681
1682 deleted = await execute_delete_webhook(repo_id=r.repo_id, webhook_id=webhook_id, actor="alice")
1683 assert deleted.ok is True
1684 assert deleted.data["deleted"] is True
1685
1686 listing = await execute_list_webhooks(repo_id=r.repo_id, actor="alice")
1687 ids = [w["webhook_id"] for w in listing.data["webhooks"]]
1688 assert webhook_id not in ids
1689
1690 async def test_create_webhook_invalid_event_type(self, db_session: AsyncSession) -> None:
1691 """Unknown event types are rejected with invalid_args."""
1692 r = await _repo(db_session, owner="alice")
1693 await db_session.commit()
1694
1695 result = await execute_create_webhook(
1696 repo_id=r.repo_id,
1697 url="https://example.com/hook",
1698 events=["not_a_real_event"],
1699 actor="alice",
1700 )
1701 assert result.ok is False
1702 assert result.error_code == "invalid_args"
1703
1704 async def test_create_webhook_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
1705 """Non-owner cannot create webhooks."""
1706 r = await _repo(db_session, owner="alice")
1707 await db_session.commit()
1708
1709 result = await execute_create_webhook(
1710 repo_id=r.repo_id,
1711 url="https://example.com/hook",
1712 events=["push"],
1713 actor="carol",
1714 )
1715 assert result.ok is False
1716 assert result.error_code == "forbidden"
1717
1718 async def test_delete_unknown_webhook_returns_error(self, db_session: AsyncSession) -> None:
1719 """Deleting a non-existent webhook returns webhook_not_found."""
1720 r = await _repo(db_session, owner="alice")
1721 await db_session.commit()
1722
1723 result = await execute_delete_webhook(
1724 repo_id=r.repo_id, webhook_id="ghost-webhook-id", actor="alice"
1725 )
1726 assert result.ok is False
1727 assert result.error_code == "webhook_not_found"
1728
1729 async def test_list_webhooks_forbidden_without_auth(self, db_session: AsyncSession) -> None:
1730 """Empty actor (unauthenticated) is forbidden from listing webhooks."""
1731 r = await _repo(db_session, owner="alice")
1732 await db_session.commit()
1733
1734 result = await execute_list_webhooks(repo_id=r.repo_id, actor="")
1735 assert result.ok is False
1736 assert result.error_code == "forbidden"
1737
1738 async def test_list_webhooks_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
1739 """Non-owner carol cannot list webhooks — they may contain sensitive URLs."""
1740 r = await _repo(db_session, owner="alice")
1741 await db_session.commit()
1742
1743 result = await execute_list_webhooks(repo_id=r.repo_id, actor="carol")
1744 assert result.ok is False
1745 assert result.error_code == "forbidden"
1746
1747
1748 class TestIntegrationReleaseAssets:
1749 """Tests for execute_attach/delete_release_asset via MCP layer."""
1750
1751 async def test_attach_and_delete_asset(self, db_session: AsyncSession) -> None:
1752 """Attaching an asset then deleting it returns deleted=True."""
1753 r = await _repo(db_session, owner="alice")
1754 await db_session.commit()
1755
1756 release_result = await execute_create_release(
1757 repo_id=r.repo_id, tag="v1.0.0", title="First release", actor="alice"
1758 )
1759 assert release_result.ok is True
1760
1761 attach = await execute_attach_release_asset(
1762 repo_id=r.repo_id,
1763 tag="v1.0.0",
1764 name="myapp-v1.0.0.tar.gz",
1765 download_url="https://cdn.example.com/myapp-v1.0.0.tar.gz",
1766 actor="alice",
1767 )
1768 assert attach.ok is True
1769 assert attach.data["name"] == "myapp-v1.0.0.tar.gz"
1770 asset_id: str = attach.data["asset_id"]
1771
1772 delete = await execute_delete_release_asset(
1773 repo_id=r.repo_id, tag="v1.0.0", asset_id=asset_id, actor="alice"
1774 )
1775 assert delete.ok is True
1776 assert delete.data["deleted"] is True
1777
1778 async def test_attach_asset_release_not_found(self, db_session: AsyncSession) -> None:
1779 """Attaching an asset to a non-existent release returns release_not_found."""
1780 r = await _repo(db_session, owner="alice")
1781 await db_session.commit()
1782
1783 result = await execute_attach_release_asset(
1784 repo_id=r.repo_id,
1785 tag="v9.9.9",
1786 name="ghost.tar.gz",
1787 download_url="https://cdn.example.com/ghost.tar.gz",
1788 actor="alice",
1789 )
1790 assert result.ok is False
1791 assert result.error_code == "release_not_found"
1792
1793 async def test_attach_asset_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
1794 """Non-owner cannot attach assets."""
1795 r = await _repo(db_session, owner="alice")
1796 await db_session.commit()
1797
1798 await execute_create_release(repo_id=r.repo_id, tag="v1.0.0", actor="alice")
1799
1800 result = await execute_attach_release_asset(
1801 repo_id=r.repo_id,
1802 tag="v1.0.0",
1803 name="evil.tar.gz",
1804 download_url="https://cdn.example.com/evil.tar.gz",
1805 actor="carol",
1806 )
1807 assert result.ok is False
1808 assert result.error_code == "forbidden"
1809
1810
1811 class TestIntegrationProposalComments:
1812 """Integration tests for execute_list_proposal_comments."""
1813
1814 async def test_list_proposal_comments_happy_path(self, db_session: AsyncSession) -> None:
1815 """list_proposal_comments returns threaded comments for a proposal."""
1816 r = await _repo(db_session, owner="alice")
1817 await _commit_and_branch(db_session, r.repo_id, "main")
1818 await _commit_and_branch(db_session, r.repo_id, "feat-pc")
1819 await db_session.commit()
1820
1821 proposal = await execute_create_proposal(
1822 repo_id=r.repo_id,
1823 title="Test proposal",
1824 from_branch="feat-pc",
1825 to_branch="main",
1826 actor="alice",
1827 )
1828 assert proposal.ok is True
1829 proposal_id: str = proposal.data["proposal_id"]
1830
1831 comment = await execute_create_proposal_comment(
1832 repo_id=r.repo_id,
1833 proposal_id=proposal_id,
1834 body="Looks good!",
1835 actor="alice",
1836 )
1837 assert comment.ok is True
1838
1839 result = await execute_list_proposal_comments(
1840 repo_id=r.repo_id,
1841 proposal_id=proposal_id,
1842 actor="alice",
1843 )
1844 assert result.ok is True
1845 assert result.data["total"] == 1
1846 assert len(result.data["comments"]) == 1
1847 assert result.data["comments"][0]["body"] == "Looks good!"
1848
1849 async def test_list_proposal_comments_requires_auth(self, db_session: AsyncSession) -> None:
1850 """list_proposal_comments requires authentication."""
1851 r = await _repo(db_session, owner="alice")
1852 await db_session.commit()
1853
1854 result = await execute_list_proposal_comments(
1855 repo_id=r.repo_id,
1856 proposal_id="any-id",
1857 actor="",
1858 )
1859 assert result.ok is False
1860 assert result.error_code == "forbidden"
1861
1862
1863 class TestIntegrationProposalReviewers:
1864 """Integration tests for request/remove proposal reviewers and list reviews."""
1865
1866 async def test_request_and_list_reviewers(self, db_session: AsyncSession) -> None:
1867 """request_proposal_reviewers creates pending rows; list_proposal_reviews returns them."""
1868 r = await _repo(db_session, owner="alice")
1869 await _commit_and_branch(db_session, r.repo_id, "main")
1870 await _commit_and_branch(db_session, r.repo_id, "feat-rv")
1871 await db_session.commit()
1872
1873 proposal = await execute_create_proposal(
1874 repo_id=r.repo_id,
1875 title="Review me",
1876 from_branch="feat-rv",
1877 to_branch="main",
1878 actor="alice",
1879 )
1880 assert proposal.ok is True
1881 proposal_id: str = proposal.data["proposal_id"]
1882
1883 req = await execute_request_proposal_reviewers(
1884 repo_id=r.repo_id,
1885 proposal_id=proposal_id,
1886 reviewers=["bob", "carol"],
1887 actor="alice",
1888 )
1889 assert req.ok is True
1890 assert req.data["total"] == 2
1891 reviewer_handles = {rv["reviewer"] for rv in req.data["reviews"]}
1892 assert "bob" in reviewer_handles
1893 assert "carol" in reviewer_handles
1894
1895 lst = await execute_list_proposal_reviews(
1896 repo_id=r.repo_id,
1897 proposal_id=proposal_id,
1898 actor="alice",
1899 )
1900 assert lst.ok is True
1901 assert lst.data["total"] == 2
1902
1903 async def test_remove_reviewer(self, db_session: AsyncSession) -> None:
1904 """remove_proposal_reviewer removes a pending reviewer."""
1905 r = await _repo(db_session, owner="alice")
1906 await _commit_and_branch(db_session, r.repo_id, "main")
1907 await _commit_and_branch(db_session, r.repo_id, "feat-rm")
1908 await db_session.commit()
1909
1910 proposal = await execute_create_proposal(
1911 repo_id=r.repo_id,
1912 title="Remove reviewer",
1913 from_branch="feat-rm",
1914 to_branch="main",
1915 actor="alice",
1916 )
1917 proposal_id: str = proposal.data["proposal_id"]
1918
1919 await execute_request_proposal_reviewers(
1920 repo_id=r.repo_id,
1921 proposal_id=proposal_id,
1922 reviewers=["bob"],
1923 actor="alice",
1924 )
1925
1926 remove = await execute_remove_proposal_reviewer(
1927 repo_id=r.repo_id,
1928 proposal_id=proposal_id,
1929 reviewer="bob",
1930 actor="alice",
1931 )
1932 assert remove.ok is True
1933 assert remove.data["total"] == 0
1934
1935 async def test_request_reviewers_forbidden_for_non_write(self, db_session: AsyncSession) -> None:
1936 """Unauthenticated user cannot request reviewers."""
1937 r = await _repo(db_session, owner="alice")
1938 await _commit_and_branch(db_session, r.repo_id, "main")
1939 await _commit_and_branch(db_session, r.repo_id, "feat-fw")
1940 await db_session.commit()
1941
1942 proposal = await execute_create_proposal(
1943 repo_id=r.repo_id,
1944 title="Forbidden",
1945 from_branch="feat-fw",
1946 to_branch="main",
1947 actor="alice",
1948 )
1949 proposal_id: str = proposal.data["proposal_id"]
1950
1951 result = await execute_request_proposal_reviewers(
1952 repo_id=r.repo_id,
1953 proposal_id=proposal_id,
1954 reviewers=["bob"],
1955 actor="",
1956 )
1957 assert result.ok is False
1958 assert result.error_code == "forbidden"
1959
1960 async def test_list_reviews_filtered_by_state(self, db_session: AsyncSession) -> None:
1961 """list_proposal_reviews state filter returns only matching rows."""
1962 r = await _repo(db_session, owner="alice")
1963 await _commit_and_branch(db_session, r.repo_id, "main")
1964 await _commit_and_branch(db_session, r.repo_id, "feat-flt")
1965 await db_session.commit()
1966
1967 proposal = await execute_create_proposal(
1968 repo_id=r.repo_id,
1969 title="Filter test",
1970 from_branch="feat-flt",
1971 to_branch="main",
1972 actor="alice",
1973 )
1974 proposal_id: str = proposal.data["proposal_id"]
1975
1976 await execute_request_proposal_reviewers(
1977 repo_id=r.repo_id,
1978 proposal_id=proposal_id,
1979 reviewers=["bob"],
1980 actor="alice",
1981 )
1982
1983 lst = await execute_list_proposal_reviews(
1984 repo_id=r.repo_id,
1985 proposal_id=proposal_id,
1986 state="pending",
1987 actor="alice",
1988 )
1989 assert lst.ok is True
1990 assert lst.data["total"] == 1
1991
1992 lst_approved = await execute_list_proposal_reviews(
1993 repo_id=r.repo_id,
1994 proposal_id=proposal_id,
1995 state="approved",
1996 actor="alice",
1997 )
1998 assert lst_approved.ok is True
1999 assert lst_approved.data["total"] == 0
2000
2001
2002 class TestIntegrationRepoManagement:
2003 """Integration tests for delete_repo, update_repo, transfer_repo_ownership."""
2004
2005 async def test_delete_repo_happy_path(self, db_session: AsyncSession) -> None:
2006 """Owner can delete their own repo."""
2007 r = await _repo(db_session, owner="alice")
2008 await db_session.commit()
2009
2010 result = await execute_delete_repo(repo_id=r.repo_id, actor="alice")
2011 assert result.ok is True
2012 assert result.data["deleted"] is True
2013 assert result.data["repo_id"] == r.repo_id
2014
2015 async def test_delete_repo_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
2016 """Non-owner cannot delete a repo."""
2017 r = await _repo(db_session, owner="alice")
2018 await db_session.commit()
2019
2020 result = await execute_delete_repo(repo_id=r.repo_id, actor="carol")
2021 assert result.ok is False
2022 assert result.error_code == "forbidden"
2023
2024 async def test_delete_repo_requires_auth(self, db_session: AsyncSession) -> None:
2025 """Unauthenticated user cannot delete a repo."""
2026 r = await _repo(db_session, owner="alice")
2027 await db_session.commit()
2028
2029 result = await execute_delete_repo(repo_id=r.repo_id, actor="")
2030 assert result.ok is False
2031 assert result.error_code == "forbidden"
2032
2033 async def test_update_repo_description(self, db_session: AsyncSession) -> None:
2034 """Owner can update the repo description."""
2035 from musehub.mcp.write_tools.repos import execute_update_repo
2036 r = await _repo(db_session, owner="alice")
2037 await db_session.commit()
2038
2039 result = await execute_update_repo(
2040 repo_id=r.repo_id,
2041 actor="alice",
2042 description="Updated description",
2043 )
2044 assert result.ok is True
2045 assert result.data["description"] == "Updated description"
2046
2047 async def test_update_repo_visibility(self, db_session: AsyncSession) -> None:
2048 """Owner can change visibility from public to private."""
2049 from musehub.mcp.write_tools.repos import execute_update_repo
2050 r = await _repo(db_session, owner="alice", visibility="public")
2051 await db_session.commit()
2052
2053 result = await execute_update_repo(
2054 repo_id=r.repo_id,
2055 actor="alice",
2056 visibility="private",
2057 )
2058 assert result.ok is True
2059 assert result.data["visibility"] == "private"
2060
2061 async def test_update_repo_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
2062 """Non-owner cannot update repo settings."""
2063 from musehub.mcp.write_tools.repos import execute_update_repo
2064 r = await _repo(db_session, owner="alice")
2065 await db_session.commit()
2066
2067 result = await execute_update_repo(
2068 repo_id=r.repo_id,
2069 actor="carol",
2070 description="Sneaky update",
2071 )
2072 assert result.ok is False
2073 assert result.error_code == "forbidden"
2074
2075 async def test_update_repo_requires_auth(self, db_session: AsyncSession) -> None:
2076 """Unauthenticated caller cannot update repo settings."""
2077 from musehub.mcp.write_tools.repos import execute_update_repo
2078 r = await _repo(db_session, owner="alice")
2079 await db_session.commit()
2080
2081 result = await execute_update_repo(
2082 repo_id=r.repo_id,
2083 actor="",
2084 description="Should fail",
2085 )
2086 assert result.ok is False
2087 assert result.error_code == "forbidden"
2088
2089 async def test_update_repo_not_found(self, db_session: AsyncSession) -> None:
2090 """Updating a non-existent repo returns repo_not_found."""
2091 from musehub.mcp.write_tools.repos import execute_update_repo
2092 result = await execute_update_repo(
2093 repo_id="00000000-0000-0000-0000-000000000000",
2094 actor="alice",
2095 name="ghost",
2096 )
2097 assert result.ok is False
2098 assert result.error_code == "repo_not_found"
2099
2100 async def test_patch_repo_settings_happy_path(self, db_session: AsyncSession) -> None:
2101 """Owner can patch repo settings."""
2102 r = await _repo(db_session, owner="alice")
2103 await db_session.commit()
2104
2105 result = await execute_update_repo(
2106 repo_id=r.repo_id,
2107 actor="alice",
2108 description="New description",
2109 visibility="private",
2110 )
2111 assert result.ok is True
2112 assert result.data["description"] == "New description"
2113 assert result.data["visibility"] == "private"
2114
2115 async def test_patch_repo_settings_forbidden_for_non_admin(self, db_session: AsyncSession) -> None:
2116 """Non-admin cannot patch repo settings."""
2117 r = await _repo(db_session, owner="alice")
2118 await db_session.commit()
2119
2120 result = await execute_update_repo(
2121 repo_id=r.repo_id,
2122 actor="carol",
2123 description="Evil update",
2124 )
2125 assert result.ok is False
2126 assert result.error_code == "forbidden"
2127
2128 async def test_transfer_repo_ownership_happy_path(self, db_session: AsyncSession) -> None:
2129 """Owner can transfer ownership to another user."""
2130 r = await _repo(db_session, owner="alice")
2131 await db_session.commit()
2132
2133 result = await execute_transfer_repo_ownership(
2134 repo_id=r.repo_id,
2135 new_owner="bob",
2136 actor="alice",
2137 )
2138 assert result.ok is True
2139 assert result.data["owner_user_id"] == "bob"
2140
2141 async def test_transfer_repo_ownership_forbidden_for_non_owner(self, db_session: AsyncSession) -> None:
2142 """Non-owner cannot transfer ownership."""
2143 r = await _repo(db_session, owner="alice")
2144 await db_session.commit()
2145
2146 result = await execute_transfer_repo_ownership(
2147 repo_id=r.repo_id,
2148 new_owner="evil",
2149 actor="carol",
2150 )
2151 assert result.ok is False
2152 assert result.error_code == "forbidden"