gabriel / musehub public
test_musehub_proposals.py python
1,548 lines 55.4 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 days ago
1 """Tests for MuseHub merge proposal endpoints.
2
3 Covers every acceptance criterion from issues #41, #215:
4 - POST /repos/{repo_id}/proposals creates proposal in open state
5 - 422 when from_branch == to_branch
6 - 404 when from_branch does not exist
7 - GET /proposals returns all proposals (open + merged + closed)
8 - GET /proposals/{proposal_id} returns full proposal detail; 404 if not found
9 - GET /proposals/{proposal_id}/diff returns five-dimension musical diff scores
10 - GET /proposals/{proposal_id}/diff graceful degradation when branches have no commits
11 - POST /proposals/{proposal_id}/merge creates merge commit, sets state merged
12 - POST /proposals/{proposal_id}/merge accepts squash and rebase strategies
13 - 409 when merging an already-merged proposal
14 - All endpoints require valid MSign auth
15 - affected_sections derived from commit message text, not structural score heuristic
16 - build_proposal_diff_response / build_zero_diff_response service helpers produce valid output
17
18 All tests use the shared ``client``, ``auth_headers``, and ``db_session``
19 fixtures from conftest.py.
20 """
21 from __future__ import annotations
22
23 import uuid
24 from datetime import datetime, timezone
25
26 import msgpack
27 import pytest
28 from httpx import AsyncClient
29 from sqlalchemy.ext.asyncio import AsyncSession
30
31 from musehub.db.musehub_models import MusehubBranch, MusehubCommit, MusehubSnapshot
32 from musehub.muse_cli.snapshot import compute_commit_id, compute_snapshot_id
33 from musehub.types.json_types import JSONObject, StrDict
34
35
36 # ---------------------------------------------------------------------------
37 # Helpers
38 # ---------------------------------------------------------------------------
39
40
41 async def _create_repo(
42 client: AsyncClient,
43 auth_headers: StrDict,
44 name: str = "neo-soul-repo",
45 ) -> str:
46 """Create a repo via the API and return its repo_id."""
47 response = await client.post(
48 "/api/repos",
49 json={"name": name, "owner": "testuser", "initialize": False},
50 headers=auth_headers,
51 )
52 assert response.status_code == 201
53 return str(response.json()["repoId"])
54
55
56 async def _push_branch(
57 db: AsyncSession,
58 repo_id: str,
59 branch_name: str,
60 ) -> str:
61 """Insert a branch with one commit so the branch exists and has a head commit.
62
63 Returns the commit_id so callers can reference it if needed.
64 """
65 commit_id = uuid.uuid4().hex
66 commit = MusehubCommit(
67 commit_id=commit_id,
68 repo_id=repo_id,
69 branch=branch_name,
70 parent_ids=[],
71 message=f"Initial commit on {branch_name}",
72 author="rene",
73 timestamp=datetime.now(tz=timezone.utc),
74 )
75 branch = MusehubBranch(
76 repo_id=repo_id,
77 name=branch_name,
78 head_commit_id=commit_id,
79 )
80 db.add(commit)
81 db.add(branch)
82 await db.commit()
83 return commit_id
84
85
86 async def _create_proposal_helper(
87 client: AsyncClient,
88 auth_headers: StrDict,
89 repo_id: str,
90 *,
91 title: str = "Add neo-soul keys variation",
92 from_branch: str = "feature",
93 to_branch: str = "main",
94 body: str = "",
95 ) -> JSONObject:
96 response = await client.post(
97 f"/api/repos/{repo_id}/proposals",
98 json={
99 "title": title,
100 "fromBranch": from_branch,
101 "toBranch": to_branch,
102 "body": body,
103 },
104 headers=auth_headers,
105 )
106 assert response.status_code == 201, response.text
107 return dict(response.json())
108
109
110 # ---------------------------------------------------------------------------
111 # POST /repos/{repo_id}/proposals
112 # ---------------------------------------------------------------------------
113
114
115 async def test_create_proposal_returns_open_state(
116 client: AsyncClient,
117 auth_headers: StrDict,
118 db_session: AsyncSession,
119 ) -> None:
120 """Proposal created via POST returns state='open' with all required fields."""
121 repo_id = await _create_repo(client, auth_headers, "proposal-open-state-repo")
122 await _push_branch(db_session, repo_id, "feature")
123
124 response = await client.post(
125 f"/api/repos/{repo_id}/proposals",
126 json={
127 "title": "Add neo-soul keys variation",
128 "fromBranch": "feature",
129 "toBranch": "main",
130 "body": "Adds dreamy chord voicings.",
131 },
132 headers=auth_headers,
133 )
134
135 assert response.status_code == 201
136 body = response.json()
137 assert body["state"] == "open"
138 assert body["title"] == "Add neo-soul keys variation"
139 assert body["fromBranch"] == "feature"
140 assert body["toBranch"] == "main"
141 assert body["body"] == "Adds dreamy chord voicings."
142 assert "proposalId" in body
143 assert "createdAt" in body
144 assert body["mergeCommitId"] is None
145
146
147 async def test_create_proposal_same_branch_returns_422(
148 client: AsyncClient,
149 auth_headers: StrDict,
150 ) -> None:
151 """Creating a proposal with from_branch == to_branch returns HTTP 422."""
152 repo_id = await _create_repo(client, auth_headers, "same-branch-repo")
153
154 response = await client.post(
155 f"/api/repos/{repo_id}/proposals",
156 json={"title": "Bad proposal", "fromBranch": "main", "toBranch": "main"},
157 headers=auth_headers,
158 )
159
160 assert response.status_code == 422
161
162
163 async def test_create_proposal_missing_from_branch_returns_404(
164 client: AsyncClient,
165 auth_headers: StrDict,
166 ) -> None:
167 """Creating a proposal when from_branch does not exist returns HTTP 404."""
168 repo_id = await _create_repo(client, auth_headers, "no-branch-repo")
169
170 response = await client.post(
171 f"/api/repos/{repo_id}/proposals",
172 json={"title": "Ghost proposal", "fromBranch": "nonexistent", "toBranch": "main"},
173 headers=auth_headers,
174 )
175
176 assert response.status_code == 404
177
178
179 async def test_create_proposal_requires_auth(client: AsyncClient) -> None:
180 """POST /proposals returns 401 without a MSign Authorization header."""
181 response = await client.post(
182 "/api/repos/any-id/proposals",
183 json={"title": "Unauthorized", "fromBranch": "feat", "toBranch": "main"},
184 )
185 assert response.status_code == 401
186
187
188 # ---------------------------------------------------------------------------
189 # GET /repos/{repo_id}/proposals
190 # ---------------------------------------------------------------------------
191
192
193 async def test_list_proposals_returns_all_states(
194 client: AsyncClient,
195 auth_headers: StrDict,
196 db_session: AsyncSession,
197 ) -> None:
198 """GET /proposals returns open AND merged proposals by default."""
199 repo_id = await _create_repo(client, auth_headers, "list-all-states-repo")
200 await _push_branch(db_session, repo_id, "feature-a")
201 await _push_branch(db_session, repo_id, "feature-b")
202 await _push_branch(db_session, repo_id, "main")
203
204 proposal_a = await _create_proposal_helper(
205 client, auth_headers, repo_id, title="Open proposal", from_branch="feature-a"
206 )
207 proposal_b = await _create_proposal_helper(
208 client, auth_headers, repo_id, title="Merged proposal", from_branch="feature-b"
209 )
210
211 # Merge proposal_b
212 await client.post(
213 f"/api/repos/{repo_id}/proposals/{proposal_b['proposalId']}/merge",
214 json={"mergeStrategy": "merge_commit"},
215 headers=auth_headers,
216 )
217
218 response = await client.get(
219 f"/api/repos/{repo_id}/proposals",
220 headers=auth_headers,
221 )
222 assert response.status_code == 200
223 all_proposals = response.json()["proposals"]
224 assert len(all_proposals) == 2
225 states = {p["state"] for p in all_proposals}
226 assert "open" in states
227 assert "merged" in states
228
229
230 async def test_list_proposals_filter_by_open(
231 client: AsyncClient,
232 auth_headers: StrDict,
233 db_session: AsyncSession,
234 ) -> None:
235 """GET /proposals?state=open returns only open proposals."""
236 repo_id = await _create_repo(client, auth_headers, "filter-open-repo")
237 await _push_branch(db_session, repo_id, "feat-open")
238 await _push_branch(db_session, repo_id, "feat-merge")
239 await _push_branch(db_session, repo_id, "main")
240
241 await _create_proposal_helper(client, auth_headers, repo_id, title="Open proposal", from_branch="feat-open")
242 proposal_to_merge = await _create_proposal_helper(
243 client, auth_headers, repo_id, title="Will merge", from_branch="feat-merge"
244 )
245 await client.post(
246 f"/api/repos/{repo_id}/proposals/{proposal_to_merge['proposalId']}/merge",
247 json={"mergeStrategy": "merge_commit"},
248 headers=auth_headers,
249 )
250
251 response = await client.get(
252 f"/api/repos/{repo_id}/proposals?state=open",
253 headers=auth_headers,
254 )
255 assert response.status_code == 200
256 open_proposals = response.json()["proposals"]
257 assert len(open_proposals) == 1
258 assert open_proposals[0]["state"] == "open"
259
260
261 async def test_list_proposals_nonexistent_repo_returns_404_without_auth(client: AsyncClient) -> None:
262 """GET /proposals returns 404 for non-existent repo without a token.
263
264 Uses optional_token — auth is visibility-based; missing repo → 404.
265 """
266 response = await client.get("/api/repos/non-existent-repo-id/proposals")
267 assert response.status_code == 404
268
269
270 # ---------------------------------------------------------------------------
271 # GET /repos/{repo_id}/proposals/{proposal_id}
272 # ---------------------------------------------------------------------------
273
274
275 async def test_get_proposal_returns_full_detail(
276 client: AsyncClient,
277 auth_headers: StrDict,
278 db_session: AsyncSession,
279 ) -> None:
280 """GET /proposals/{proposal_id} returns the full proposal object."""
281 repo_id = await _create_repo(client, auth_headers, "get-detail-repo")
282 await _push_branch(db_session, repo_id, "keys-variation")
283
284 created = await _create_proposal_helper(
285 client,
286 auth_headers,
287 repo_id,
288 title="Keys variation",
289 from_branch="keys-variation",
290 body="Dreamy neo-soul voicings",
291 )
292
293 response = await client.get(
294 f"/api/repos/{repo_id}/proposals/{created['proposalId']}",
295 headers=auth_headers,
296 )
297 assert response.status_code == 200
298 body = response.json()
299 assert body["proposalId"] == created["proposalId"]
300 assert body["title"] == "Keys variation"
301 assert body["body"] == "Dreamy neo-soul voicings"
302 assert body["state"] == "open"
303
304
305 async def test_get_proposal_unknown_id_returns_404(
306 client: AsyncClient,
307 auth_headers: StrDict,
308 ) -> None:
309 """GET /proposals/{unknown_proposal_id} returns 404."""
310 repo_id = await _create_repo(client, auth_headers, "get-404-repo")
311
312 response = await client.get(
313 f"/api/repos/{repo_id}/proposals/does-not-exist",
314 headers=auth_headers,
315 )
316 assert response.status_code == 404
317
318
319 async def test_get_proposal_nonexistent_returns_404_without_auth(client: AsyncClient) -> None:
320 """GET /proposals/{proposal_id} returns 404 for non-existent resource without a token.
321
322 Uses optional_token — auth is visibility-based; missing repo/proposal → 404.
323 """
324 response = await client.get("/api/repos/non-existent-repo/proposals/non-existent-proposal")
325 assert response.status_code == 404
326
327
328 # ---------------------------------------------------------------------------
329 # POST /repos/{repo_id}/proposals/{proposal_id}/merge
330 # ---------------------------------------------------------------------------
331
332
333 async def test_merge_proposal_creates_merge_commit(
334 client: AsyncClient,
335 auth_headers: StrDict,
336 db_session: AsyncSession,
337 ) -> None:
338 """Merging a proposal creates a merge commit and sets state to 'merged'."""
339 repo_id = await _create_repo(client, auth_headers, "merge-commit-repo")
340 await _push_branch(db_session, repo_id, "neo-soul")
341 await _push_branch(db_session, repo_id, "main")
342
343 p = await _create_proposal_helper(
344 client, auth_headers, repo_id, title="Neo-soul merge", from_branch="neo-soul"
345 )
346
347 response = await client.post(
348 f"/api/repos/{repo_id}/proposals/{p['proposalId']}/merge",
349 json={"mergeStrategy": "merge_commit"},
350 headers=auth_headers,
351 )
352
353 assert response.status_code == 200
354 body = response.json()
355 assert body["merged"] is True
356 assert "mergeCommitId" in body
357 assert body["mergeCommitId"] is not None
358
359 # Verify proposal state changed to merged
360 detail = await client.get(
361 f"/api/repos/{repo_id}/proposals/{p['proposalId']}",
362 headers=auth_headers,
363 )
364 assert detail.json()["state"] == "merged"
365 assert detail.json()["mergeCommitId"] == body["mergeCommitId"]
366
367
368 async def test_merge_already_merged_returns_409(
369 client: AsyncClient,
370 auth_headers: StrDict,
371 db_session: AsyncSession,
372 ) -> None:
373 """Merging an already-merged proposal returns HTTP 409 Conflict."""
374 repo_id = await _create_repo(client, auth_headers, "double-merge-repo")
375 await _push_branch(db_session, repo_id, "feature-dup")
376 await _push_branch(db_session, repo_id, "main")
377
378 p = await _create_proposal_helper(
379 client, auth_headers, repo_id, title="Duplicate merge", from_branch="feature-dup"
380 )
381
382 # First merge succeeds
383 first = await client.post(
384 f"/api/repos/{repo_id}/proposals/{p['proposalId']}/merge",
385 json={"mergeStrategy": "merge_commit"},
386 headers=auth_headers,
387 )
388 assert first.status_code == 200
389
390 # Second merge must 409
391 second = await client.post(
392 f"/api/repos/{repo_id}/proposals/{p['proposalId']}/merge",
393 json={"mergeStrategy": "merge_commit"},
394 headers=auth_headers,
395 )
396 assert second.status_code == 409
397
398
399 async def test_merge_proposal_requires_auth(client: AsyncClient) -> None:
400 """POST /proposals/{proposal_id}/merge returns 401 without a MSign Authorization header."""
401 response = await client.post(
402 "/api/repos/r/proposals/p/merge",
403 json={"mergeStrategy": "merge_commit"},
404 )
405 assert response.status_code == 401
406
407
408 async def test_merge_proposal_forbidden_for_non_owner(
409 client: AsyncClient,
410 auth_headers: StrDict,
411 db_session: AsyncSession,
412 ) -> None:
413 """POST /proposals/{proposal_id}/merge as a non-owner returns 403."""
414 from musehub.db.musehub_models import MusehubRepo
415
416 other_repo = MusehubRepo(
417 name="private-merge-repo",
418 owner="other-owner",
419 slug="private-merge-repo",
420 visibility="private",
421 owner_user_id="uid-other",
422 )
423 db_session.add(other_repo)
424 await db_session.commit()
425
426 response = await client.post(
427 f"/api/repos/{other_repo.repo_id}/proposals/any-proposal-id/merge",
428 json={"mergeStrategy": "merge_commit"},
429 headers=auth_headers,
430 )
431 assert response.status_code == 403
432
433
434 # ---------------------------------------------------------------------------
435 # Regression tests — author field on proposal
436 # ---------------------------------------------------------------------------
437
438
439 async def test_create_proposal_author_in_response(
440 client: AsyncClient,
441 auth_headers: StrDict,
442 db_session: AsyncSession,
443 ) -> None:
444 """POST /proposals response includes the author field (caller handle) — regression f."""
445 repo_id = await _create_repo(client, auth_headers, "author-proposal-repo")
446 await _push_branch(db_session, repo_id, "feat/author-test")
447 response = await client.post(
448 f"/api/repos/{repo_id}/proposals",
449 json={
450 "title": "Author field regression",
451 "body": "",
452 "fromBranch": "feat/author-test",
453 "toBranch": "main",
454 },
455 headers=auth_headers,
456 )
457 assert response.status_code == 201
458 body = response.json()
459 assert "author" in body
460 assert isinstance(body["author"], str)
461
462
463 async def test_create_proposal_author_persisted_in_list(
464 client: AsyncClient,
465 auth_headers: StrDict,
466 db_session: AsyncSession,
467 ) -> None:
468 """Author field is persisted and returned in the proposal list endpoint — regression f."""
469 repo_id = await _create_repo(client, auth_headers, "author-proposal-list-repo")
470 await _push_branch(db_session, repo_id, "feat/author-list-test")
471 await client.post(
472 f"/api/repos/{repo_id}/proposals",
473 json={
474 "title": "Authored proposal",
475 "body": "",
476 "fromBranch": "feat/author-list-test",
477 "toBranch": "main",
478 },
479 headers=auth_headers,
480 )
481 list_response = await client.get(
482 f"/api/repos/{repo_id}/proposals",
483 headers=auth_headers,
484 )
485 assert list_response.status_code == 200
486 items = list_response.json()["proposals"]
487 assert len(items) == 1
488 assert "author" in items[0]
489 assert isinstance(items[0]["author"], str)
490
491
492 async def test_proposal_diff_endpoint_returns_five_dimensions(
493 client: AsyncClient,
494 auth_headers: StrDict,
495 db_session: AsyncSession,
496 ) -> None:
497 """GET /proposals/{proposal_id}/diff returns per-dimension scores for the proposal branches."""
498 repo_id = await _create_repo(client, auth_headers, "diff-proposal-repo")
499 await _push_branch(db_session, repo_id, "feat/jazz-keys")
500 proposal_resp = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/jazz-keys", to_branch="main")
501 proposal_id = proposal_resp["proposalId"]
502
503 response = await client.get(
504 f"/api/repos/{repo_id}/proposals/{proposal_id}/diff",
505 headers=auth_headers,
506 )
507 assert response.status_code == 200
508 data = response.json()
509 assert "dimensions" in data
510 assert len(data["dimensions"]) == 5
511 assert data["proposalId"] == proposal_id
512 assert data["fromBranch"] == "feat/jazz-keys"
513 assert data["toBranch"] == "main"
514 assert "overallScore" in data
515 assert isinstance(data["overallScore"], float)
516
517 # Every dimension must have the expected fields
518 for dim in data["dimensions"]:
519 assert "dimension" in dim
520 assert dim["dimension"] in ("melodic", "harmonic", "rhythmic", "structural", "dynamic")
521 assert "score" in dim
522 assert 0.0 <= dim["score"] <= 1.0
523 assert "level" in dim
524 assert dim["level"] in ("NONE", "LOW", "MED", "HIGH")
525 assert "deltaLabel" in dim
526 assert "fromBranchCommits" in dim
527 assert "toBranchCommits" in dim
528
529
530 async def test_proposal_diff_endpoint_404_for_unknown_proposal(
531 client: AsyncClient,
532 auth_headers: StrDict,
533 db_session: AsyncSession,
534 ) -> None:
535 """GET /proposals/{proposal_id}/diff returns 404 when the proposal does not exist."""
536 repo_id = await _create_repo(client, auth_headers, "diff-404-repo")
537 response = await client.get(
538 f"/api/repos/{repo_id}/proposals/nonexistent-proposal-id/diff",
539 headers=auth_headers,
540 )
541 assert response.status_code == 404
542
543
544 async def test_proposal_diff_endpoint_graceful_when_no_commits(
545 client: AsyncClient,
546 auth_headers: StrDict,
547 db_session: AsyncSession,
548 ) -> None:
549 """Diff endpoint returns zero scores when branches have no commits (graceful degradation).
550
551 When from_branch has commits but to_branch ('main') has none, compute_hub_divergence
552 raises ValueError. The diff endpoint must catch it and return zero-score placeholders
553 so the proposal detail page always renders.
554 """
555 from musehub.db.musehub_models import MusehubBranch, MusehubCommit, MusehubProposal
556
557 repo_id = await _create_repo(client, auth_headers, "diff-empty-repo")
558
559 # Seed from_branch with a commit so the proposal can be created.
560 commit_id = uuid.uuid4().hex
561 commit = MusehubCommit(
562 commit_id=commit_id,
563 repo_id=repo_id,
564 branch="feat/empty-grace",
565 parent_ids=[],
566 message="Initial commit on feat/empty-grace",
567 author="musician",
568 timestamp=datetime.now(tz=timezone.utc),
569 )
570 branch = MusehubBranch(
571 repo_id=repo_id,
572 name="feat/empty-grace",
573 head_commit_id=commit_id,
574 )
575 db_session.add(commit)
576 db_session.add(branch)
577
578 # to_branch 'main' deliberately has NO commits — divergence will raise ValueError.
579 proposal = MusehubProposal(
580 repo_id=repo_id,
581 proposal_number=1,
582 title="Grace proposal",
583 body="",
584 state="open",
585 from_branch="feat/empty-grace",
586 to_branch="main",
587 author="musician",
588 )
589 db_session.add(proposal)
590 await db_session.flush()
591 await db_session.refresh(proposal)
592 proposal_id = proposal.proposal_id
593 await db_session.commit()
594
595 response = await client.get(
596 f"/api/repos/{repo_id}/proposals/{proposal_id}/diff",
597 headers=auth_headers,
598 )
599 assert response.status_code == 200
600 data = response.json()
601 assert len(data["dimensions"]) == 5
602 assert data["overallScore"] == 0.0
603 for dim in data["dimensions"]:
604 assert dim["score"] == 0.0
605 assert dim["level"] == "NONE"
606 assert dim["deltaLabel"] == "unchanged"
607
608
609 async def test_proposal_merge_strategy_squash_accepted(
610 client: AsyncClient,
611 auth_headers: StrDict,
612 db_session: AsyncSession,
613 ) -> None:
614 """POST /proposals/{proposal_id}/merge accepts 'squash' as a valid mergeStrategy."""
615 repo_id = await _create_repo(client, auth_headers, "strategy-squash-repo")
616 await _push_branch(db_session, repo_id, "feat/squash-test")
617 await _push_branch(db_session, repo_id, "main")
618 proposal_resp = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/squash-test", to_branch="main")
619 proposal_id = proposal_resp["proposalId"]
620
621 response = await client.post(
622 f"/api/repos/{repo_id}/proposals/{proposal_id}/merge",
623 json={"mergeStrategy": "squash"},
624 headers=auth_headers,
625 )
626 # squash is now a valid strategy in the Pydantic model; merge logic uses merge_commit internally
627 assert response.status_code == 200
628 data = response.json()
629 assert data["merged"] is True
630
631
632 async def test_proposal_merge_strategy_rebase_accepted(
633 client: AsyncClient,
634 auth_headers: StrDict,
635 db_session: AsyncSession,
636 ) -> None:
637 """POST /proposals/{proposal_id}/merge accepts 'rebase' as a valid mergeStrategy."""
638 repo_id = await _create_repo(client, auth_headers, "strategy-rebase-repo")
639 await _push_branch(db_session, repo_id, "feat/rebase-test")
640 await _push_branch(db_session, repo_id, "main")
641 proposal_resp = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/rebase-test", to_branch="main")
642 proposal_id = proposal_resp["proposalId"]
643
644 response = await client.post(
645 f"/api/repos/{repo_id}/proposals/{proposal_id}/merge",
646 json={"mergeStrategy": "rebase"},
647 headers=auth_headers,
648 )
649 assert response.status_code == 200
650 data = response.json()
651 assert data["merged"] is True
652
653
654 # ---------------------------------------------------------------------------
655 # Proposal review comments — # ---------------------------------------------------------------------------
656
657
658 async def test_create_proposal_comment(
659 client: AsyncClient,
660 auth_headers: StrDict,
661 db_session: AsyncSession,
662 ) -> None:
663 """POST /proposals/{proposal_id}/comments creates a comment and returns threaded list."""
664 repo_id = await _create_repo(client, auth_headers, "comment-create-repo")
665 await _push_branch(db_session, repo_id, "feat/comment-test")
666 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/comment-test")
667
668 response = await client.post(
669 f"/api/repos/{repo_id}/proposals/{p['proposalId']}/comments",
670 json={"body": "The bass line feels stiff — add swing.", "targetType": "general"},
671 headers=auth_headers,
672 )
673 assert response.status_code == 201
674 data = response.json()
675 assert "comments" in data
676 assert "total" in data
677 assert data["total"] == 1
678 comment = data["comments"][0]
679 assert comment["body"] == "The bass line feels stiff — add swing."
680 assert comment["targetType"] == "general"
681 assert "commentId" in comment
682 assert "createdAt" in comment
683
684
685 async def test_list_proposal_comments_threaded(
686 client: AsyncClient,
687 auth_headers: StrDict,
688 db_session: AsyncSession,
689 ) -> None:
690 """GET /proposals/{proposal_id}/comments returns top-level comments with nested replies."""
691 repo_id = await _create_repo(client, auth_headers, "comment-list-repo")
692 await _push_branch(db_session, repo_id, "feat/list-comments")
693 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/list-comments")
694 proposal_id = p["proposalId"]
695
696 # Create a top-level comment
697 create_resp = await client.post(
698 f"/api/repos/{repo_id}/proposals/{proposal_id}/comments",
699 json={"body": "Top-level comment.", "targetType": "general"},
700 headers=auth_headers,
701 )
702 assert create_resp.status_code == 201
703 parent_id = create_resp.json()["comments"][0]["commentId"]
704
705 # Reply to it
706 reply_resp = await client.post(
707 f"/api/repos/{repo_id}/proposals/{proposal_id}/comments",
708 json={"body": "A reply.", "targetType": "general", "parentCommentId": parent_id},
709 headers=auth_headers,
710 )
711 assert reply_resp.status_code == 201
712
713 # Fetch threaded list
714 list_resp = await client.get(
715 f"/api/repos/{repo_id}/proposals/{proposal_id}/comments",
716 headers=auth_headers,
717 )
718 assert list_resp.status_code == 200
719 data = list_resp.json()
720 assert data["total"] == 2
721 # Only one top-level comment
722 assert len(data["comments"]) == 1
723 top = data["comments"][0]
724 assert len(top["replies"]) == 1
725 assert top["replies"][0]["body"] == "A reply."
726
727
728 async def test_comment_targets_track(
729 client: AsyncClient,
730 auth_headers: StrDict,
731 db_session: AsyncSession,
732 ) -> None:
733 """POST /comments with target_type=region stores track and beat range correctly."""
734 repo_id = await _create_repo(client, auth_headers, "comment-track-repo")
735 await _push_branch(db_session, repo_id, "feat/track-comment")
736 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/track-comment")
737
738 response = await client.post(
739 f"/api/repos/{repo_id}/proposals/{p['proposalId']}/comments",
740 json={
741 "body": "Beats 16-24 on bass feel rushed.",
742 "targetType": "region",
743 "targetTrack": "bass",
744 "targetBeatStart": 16.0,
745 "targetBeatEnd": 24.0,
746 },
747 headers=auth_headers,
748 )
749 assert response.status_code == 201
750 comment = response.json()["comments"][0]
751 assert comment["targetType"] == "region"
752 assert comment["targetTrack"] == "bass"
753 assert comment["targetBeatStart"] == 16.0
754 assert comment["targetBeatEnd"] == 24.0
755
756
757 async def test_comment_requires_auth(client: AsyncClient) -> None:
758 """POST /proposals/{proposal_id}/comments returns 401 without a MSign Authorization header."""
759 response = await client.post(
760 "/api/repos/r/proposals/p/comments",
761 json={"body": "Unauthorized attempt."},
762 )
763 assert response.status_code == 401
764
765
766 async def test_reply_to_comment(
767 client: AsyncClient,
768 auth_headers: StrDict,
769 db_session: AsyncSession,
770 ) -> None:
771 """Replying to a comment creates a threaded child visible in the list."""
772 repo_id = await _create_repo(client, auth_headers, "comment-reply-repo")
773 await _push_branch(db_session, repo_id, "feat/reply-test")
774 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/reply-test")
775 proposal_id = p["proposalId"]
776
777 parent_resp = await client.post(
778 f"/api/repos/{repo_id}/proposals/{proposal_id}/comments",
779 json={"body": "Original comment.", "targetType": "general"},
780 headers=auth_headers,
781 )
782 parent_id = parent_resp.json()["comments"][0]["commentId"]
783
784 reply_resp = await client.post(
785 f"/api/repos/{repo_id}/proposals/{proposal_id}/comments",
786 json={"body": "Reply here.", "targetType": "general", "parentCommentId": parent_id},
787 headers=auth_headers,
788 )
789 assert reply_resp.status_code == 201
790 data = reply_resp.json()
791 # Still only one top-level comment; total is 2
792 assert data["total"] == 2
793 assert len(data["comments"]) == 1
794 reply = data["comments"][0]["replies"][0]
795 assert reply["body"] == "Reply here."
796 assert reply["parentCommentId"] == parent_id
797
798
799 # ---------------------------------------------------------------------------
800 # Issue #384 — affected_sections and divergence service helpers
801 # ---------------------------------------------------------------------------
802
803
804 def test_extract_affected_sections_returns_empty_when_no_keywords() -> None:
805 """affected_sections is empty when no commit mentions a section keyword."""
806 from musehub.services.musehub_divergence import extract_affected_sections
807
808 messages: tuple[str, ...] = (
809 "add jazzy chord voicing",
810 "fix drum quantization",
811 "update harmonic progression",
812 )
813 assert extract_affected_sections(messages) == []
814
815
816 def test_extract_affected_sections_returns_only_mentioned_keywords() -> None:
817 """affected_sections lists only the sections actually named in commits."""
818 from musehub.services.musehub_divergence import extract_affected_sections
819
820 messages: tuple[str, ...] = (
821 "rework the chorus melody",
822 "add a new bridge transition",
823 "fix drum quantization",
824 )
825 result = extract_affected_sections(messages)
826 assert "Chorus" in result
827 assert "Bridge" in result
828 assert "Verse" not in result
829 assert "Intro" not in result
830 assert "Outro" not in result
831
832
833 def test_extract_affected_sections_case_insensitive() -> None:
834 """Keyword matching is case-insensitive."""
835 from musehub.services.musehub_divergence import extract_affected_sections
836
837 messages: tuple[str, ...] = ("rewrite VERSE chord progression",)
838 result = extract_affected_sections(messages)
839 assert result == ["Verse"]
840
841
842 def test_extract_affected_sections_deduplicates() -> None:
843 """The same keyword appearing in multiple commits is only returned once."""
844 from musehub.services.musehub_divergence import extract_affected_sections
845
846 messages: tuple[str, ...] = (
847 "update chorus dynamics",
848 "fix chorus timing",
849 "tweak chorus reverb",
850 )
851 result = extract_affected_sections(messages)
852 assert result.count("Chorus") == 1
853
854
855 def test_build_zero_diff_response_structure() -> None:
856 """build_zero_diff_response returns five dimensions all at score 0.0."""
857 from musehub.services.musehub_divergence import ALL_DIMENSIONS, build_zero_diff_response
858
859 resp = build_zero_diff_response(
860 proposal_id="proposal-abc",
861 repo_id="repo-xyz",
862 from_branch="feat/test",
863 to_branch="main",
864 )
865 assert resp.proposal_id == "proposal-abc"
866 assert resp.repo_id == "repo-xyz"
867 assert resp.from_branch == "feat/test"
868 assert resp.to_branch == "main"
869 assert resp.overall_score == 0.0
870 assert resp.common_ancestor is None
871 assert resp.affected_sections == []
872 assert len(resp.dimensions) == len(ALL_DIMENSIONS)
873 for dim in resp.dimensions:
874 assert dim.score == 0.0
875 assert dim.level == "NONE"
876 assert dim.delta_label == "unchanged"
877
878
879 def test_build_proposal_diff_response_affected_sections_uses_commit_messages() -> None:
880 """build_proposal_diff_response derives affected_sections from commit messages, not score heuristic."""
881 from musehub.services.musehub_divergence import (
882 MuseHubDimensionDivergence,
883 MuseHubDivergenceLevel,
884 MuseHubDivergenceResult,
885 build_proposal_diff_response,
886 )
887
888 # Structural score > 0, but NO section keyword in any commit message.
889 structural_dim = MuseHubDimensionDivergence(
890 dimension="structural",
891 level=MuseHubDivergenceLevel.LOW,
892 score=0.3,
893 description="Minor structural divergence.",
894 branch_a_commits=1,
895 branch_b_commits=0,
896 )
897 result = MuseHubDivergenceResult(
898 repo_id="repo-1",
899 branch_a="main",
900 branch_b="feat/changes",
901 common_ancestor="abc123",
902 dimensions=(structural_dim,),
903 overall_score=0.3,
904 all_messages=("refactor arrangement flow", "update drum pattern"),
905 )
906 resp = build_proposal_diff_response(
907 proposal_id="proposal-1",
908 from_branch="feat/changes",
909 to_branch="main",
910 result=result,
911 )
912 # No section keyword in commit messages → empty list, even though structural score > 0
913 assert resp.affected_sections == []
914
915
916 # ---------------------------------------------------------------------------
917 # Proposal reviewer assignment endpoints — # ---------------------------------------------------------------------------
918
919
920 async def test_request_reviewers_creates_pending_rows(
921 client: AsyncClient,
922 auth_headers: StrDict,
923 db_session: AsyncSession,
924 ) -> None:
925 """POST /reviewers creates pending review rows for each requested username."""
926 repo_id = await _create_repo(client, auth_headers, "reviewer-create-repo")
927 await _push_branch(db_session, repo_id, "feat/reviewer-test")
928 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/reviewer-test")
929 proposal_id = p["proposalId"]
930
931 response = await client.post(
932 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers",
933 json={"reviewers": ["alice", "bob"]},
934 headers=auth_headers,
935 )
936 assert response.status_code == 201
937 data = response.json()
938 assert "reviews" in data
939 assert data["total"] == 2
940 usernames = {r["reviewerUsername"] for r in data["reviews"]}
941 assert usernames == {"alice", "bob"}
942 for review in data["reviews"]:
943 assert review["state"] == "pending"
944 assert review["submittedAt"] is None
945
946
947 async def test_request_reviewers_idempotent(
948 client: AsyncClient,
949 auth_headers: StrDict,
950 db_session: AsyncSession,
951 ) -> None:
952 """Re-requesting the same reviewer does not create a duplicate row."""
953 repo_id = await _create_repo(client, auth_headers, "reviewer-idempotent-repo")
954 await _push_branch(db_session, repo_id, "feat/idempotent")
955 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/idempotent")
956 proposal_id = p["proposalId"]
957
958 await client.post(
959 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers",
960 json={"reviewers": ["alice"]},
961 headers=auth_headers,
962 )
963 # Second request for the same reviewer
964 response = await client.post(
965 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers",
966 json={"reviewers": ["alice"]},
967 headers=auth_headers,
968 )
969 assert response.status_code == 201
970 assert response.json()["total"] == 1 # still only one row
971
972
973 async def test_request_reviewers_requires_auth(client: AsyncClient) -> None:
974 """POST /reviewers returns 401 without a MSign Authorization header."""
975 response = await client.post(
976 "/api/repos/r/proposals/p/reviewers",
977 json={"reviewers": ["alice"]},
978 )
979 assert response.status_code == 401
980
981
982 async def test_remove_reviewer_deletes_pending_row(
983 client: AsyncClient,
984 auth_headers: StrDict,
985 db_session: AsyncSession,
986 ) -> None:
987 """DELETE /reviewers/{username} removes a pending reviewer assignment."""
988 repo_id = await _create_repo(client, auth_headers, "reviewer-delete-repo")
989 await _push_branch(db_session, repo_id, "feat/remove-reviewer")
990 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/remove-reviewer")
991 proposal_id = p["proposalId"]
992
993 await client.post(
994 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers",
995 json={"reviewers": ["alice", "bob"]},
996 headers=auth_headers,
997 )
998
999 response = await client.delete(
1000 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers/alice",
1001 headers=auth_headers,
1002 )
1003 assert response.status_code == 200
1004 data = response.json()
1005 assert data["total"] == 1
1006 assert data["reviews"][0]["reviewerUsername"] == "bob"
1007
1008
1009 async def test_remove_reviewer_not_found_returns_404(
1010 client: AsyncClient,
1011 auth_headers: StrDict,
1012 db_session: AsyncSession,
1013 ) -> None:
1014 """DELETE /reviewers/{username} returns 404 when the reviewer was never requested."""
1015 repo_id = await _create_repo(client, auth_headers, "reviewer-404-repo")
1016 await _push_branch(db_session, repo_id, "feat/remove-404")
1017 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/remove-404")
1018 proposal_id = p["proposalId"]
1019
1020 response = await client.delete(
1021 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers/nobody",
1022 headers=auth_headers,
1023 )
1024 assert response.status_code == 404
1025
1026
1027 # ---------------------------------------------------------------------------
1028 # Proposal review submission endpoints — # ---------------------------------------------------------------------------
1029
1030
1031 async def test_list_reviews_empty_for_new_proposal(
1032 client: AsyncClient,
1033 auth_headers: StrDict,
1034 db_session: AsyncSession,
1035 ) -> None:
1036 """GET /reviews returns an empty list for a proposal with no reviews assigned."""
1037 repo_id = await _create_repo(client, auth_headers, "reviews-empty-repo")
1038 await _push_branch(db_session, repo_id, "feat/list-reviews-empty")
1039 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/list-reviews-empty")
1040 proposal_id = p["proposalId"]
1041
1042 response = await client.get(
1043 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
1044 headers=auth_headers,
1045 )
1046 assert response.status_code == 200
1047 data = response.json()
1048 assert data["total"] == 0
1049 assert data["reviews"] == []
1050
1051
1052 async def test_list_reviews_filter_by_state(
1053 client: AsyncClient,
1054 auth_headers: StrDict,
1055 db_session: AsyncSession,
1056 ) -> None:
1057 """GET /reviews?state=pending returns only pending reviews."""
1058 repo_id = await _create_repo(client, auth_headers, "reviews-filter-repo")
1059 await _push_branch(db_session, repo_id, "feat/filter-state")
1060 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/filter-state")
1061 proposal_id = p["proposalId"]
1062
1063 await client.post(
1064 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers",
1065 json={"reviewers": ["alice", "bob"]},
1066 headers=auth_headers,
1067 )
1068
1069 response = await client.get(
1070 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews?state=pending",
1071 headers=auth_headers,
1072 )
1073 assert response.status_code == 200
1074 data = response.json()
1075 assert data["total"] == 2
1076 for r in data["reviews"]:
1077 assert r["state"] == "pending"
1078
1079
1080 async def test_submit_review_approve(
1081 client: AsyncClient,
1082 auth_headers: StrDict,
1083 db_session: AsyncSession,
1084 ) -> None:
1085 """POST /reviews with event=approve sets state to approved and records submitted_at."""
1086 repo_id = await _create_repo(client, auth_headers, "review-approve-repo")
1087 await _push_branch(db_session, repo_id, "feat/approve-test")
1088 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/approve-test")
1089 proposal_id = p["proposalId"]
1090
1091 response = await client.post(
1092 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
1093 json={"event": "approve", "body": "Sounds great — the harmonic transitions are perfect."},
1094 headers=auth_headers,
1095 )
1096 assert response.status_code == 201
1097 data = response.json()
1098 assert data["state"] == "approved"
1099 assert data["submittedAt"] is not None
1100 assert "Sounds great" in (data["body"] or "")
1101
1102
1103 async def test_submit_review_request_changes(
1104 client: AsyncClient,
1105 auth_headers: StrDict,
1106 db_session: AsyncSession,
1107 ) -> None:
1108 """POST /reviews with event=request_changes sets state to changes_requested."""
1109 repo_id = await _create_repo(client, auth_headers, "review-changes-repo")
1110 await _push_branch(db_session, repo_id, "feat/changes-test")
1111 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/changes-test")
1112 proposal_id = p["proposalId"]
1113
1114 response = await client.post(
1115 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
1116 json={"event": "request_changes", "body": "The bridge needs more harmonic tension."},
1117 headers=auth_headers,
1118 )
1119 assert response.status_code == 201
1120 data = response.json()
1121 assert data["state"] == "changes_requested"
1122 assert data["submittedAt"] is not None
1123
1124
1125 async def test_submit_review_updates_existing_row(
1126 client: AsyncClient,
1127 auth_headers: StrDict,
1128 db_session: AsyncSession,
1129 ) -> None:
1130 """Submitting a second review replaces the existing row state in-place."""
1131 repo_id = await _create_repo(client, auth_headers, "review-update-repo")
1132 await _push_branch(db_session, repo_id, "feat/update-review")
1133 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/update-review")
1134 proposal_id = p["proposalId"]
1135
1136 # First: request changes
1137 await client.post(
1138 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
1139 json={"event": "request_changes", "body": "Not happy with the bridge."},
1140 headers=auth_headers,
1141 )
1142
1143 # After author fixes, reviewer now approves
1144 response = await client.post(
1145 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
1146 json={"event": "approve", "body": "Looks good now!"},
1147 headers=auth_headers,
1148 )
1149 assert response.status_code == 201
1150 data = response.json()
1151 assert data["state"] == "approved"
1152
1153 # Only one review row should exist
1154 list_resp = await client.get(
1155 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
1156 headers=auth_headers,
1157 )
1158 assert list_resp.json()["total"] == 1
1159
1160
1161 async def test_remove_reviewer_after_submit_returns_409(
1162 client: AsyncClient,
1163 auth_headers: StrDict,
1164 db_session: AsyncSession,
1165 ) -> None:
1166 """DELETE /reviewers/{username} returns 409 when reviewer already submitted a review.
1167
1168 The test context handle is 'testuser'. Submitting a review via POST /reviews
1169 creates a row with that handle as reviewer_username, and state=approved.
1170 Attempting to DELETE that reviewer must return 409 because the row is no
1171 longer pending.
1172 """
1173 reviewer_handle = "testuser"
1174
1175 repo_id = await _create_repo(client, auth_headers, "reviewer-submitted-repo")
1176 await _push_branch(db_session, repo_id, "feat/submitted-review")
1177 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/submitted-review")
1178 proposal_id = p["proposalId"]
1179
1180 # Submit a review — this creates an "approved" row for the test context handle
1181 submit_resp = await client.post(
1182 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
1183 json={"event": "approve", "body": "Approved"},
1184 headers=auth_headers,
1185 )
1186 assert submit_resp.status_code == 201
1187
1188 # Attempting to remove the reviewer whose row is already approved must return 409
1189 response = await client.delete(
1190 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviewers/{reviewer_handle}",
1191 headers=auth_headers,
1192 )
1193 assert response.status_code == 409
1194
1195
1196 async def test_submit_review_invalid_event_returns_422(
1197 client: AsyncClient,
1198 auth_headers: StrDict,
1199 db_session: AsyncSession,
1200 ) -> None:
1201 """POST /reviews with an invalid event value returns 422 Unprocessable Entity."""
1202 repo_id = await _create_repo(client, auth_headers, "review-invalid-event-repo")
1203 await _push_branch(db_session, repo_id, "feat/invalid-event")
1204 p = await _create_proposal_helper(client, auth_headers, repo_id, from_branch="feat/invalid-event")
1205 proposal_id = p["proposalId"]
1206
1207 response = await client.post(
1208 f"/api/repos/{repo_id}/proposals/{proposal_id}/reviews",
1209 json={"event": "INVALID", "body": ""},
1210 headers=auth_headers,
1211 )
1212 assert response.status_code == 422
1213
1214
1215 async def test_submit_review_forbidden_on_private_repo_for_non_owner(
1216 client: AsyncClient,
1217 auth_headers: StrDict,
1218 db_session: AsyncSession,
1219 ) -> None:
1220 """POST /reviews on a private repo owned by another user returns 403."""
1221 from musehub.db.musehub_models import MusehubRepo
1222
1223 other_repo = MusehubRepo(
1224 name="private-review-repo",
1225 owner="other-owner",
1226 slug="private-review-repo",
1227 visibility="private",
1228 owner_user_id="uid-other",
1229 )
1230 db_session.add(other_repo)
1231 await db_session.commit()
1232
1233 response = await client.post(
1234 f"/api/repos/{other_repo.repo_id}/proposals/any-proposal-id/reviews",
1235 json={"event": "approve", "body": ""},
1236 headers=auth_headers,
1237 )
1238 assert response.status_code == 403
1239
1240
1241 async def test_create_proposal_comment_forbidden_for_non_owner(
1242 client: AsyncClient,
1243 auth_headers: StrDict,
1244 db_session: AsyncSession,
1245 ) -> None:
1246 """POST /proposals/{proposal_id}/comments on a private repo owned by another user returns 403."""
1247 from musehub.db.musehub_models import MusehubRepo
1248
1249 other_repo = MusehubRepo(
1250 name="private-comment-repo",
1251 owner="other-owner",
1252 slug="private-comment-repo",
1253 visibility="private",
1254 owner_user_id="uid-other",
1255 )
1256 db_session.add(other_repo)
1257 await db_session.commit()
1258
1259 response = await client.post(
1260 f"/api/repos/{other_repo.repo_id}/proposals/any-proposal-id/comments",
1261 json={"body": "test comment", "targetType": "general"},
1262 headers=auth_headers,
1263 )
1264 assert response.status_code == 403
1265
1266
1267 def test_build_proposal_diff_response_affected_sections_non_empty_when_keywords_present() -> None:
1268 """build_proposal_diff_response populates affected_sections from commit message keywords."""
1269 from musehub.services.musehub_divergence import (
1270 MuseHubDimensionDivergence,
1271 MuseHubDivergenceLevel,
1272 MuseHubDivergenceResult,
1273 build_proposal_diff_response,
1274 )
1275
1276 structural_dim = MuseHubDimensionDivergence(
1277 dimension="structural",
1278 level=MuseHubDivergenceLevel.LOW,
1279 score=0.3,
1280 description="Minor structural divergence.",
1281 branch_a_commits=2,
1282 branch_b_commits=1,
1283 )
1284 result = MuseHubDivergenceResult(
1285 repo_id="repo-2",
1286 branch_a="main",
1287 branch_b="feat/rewrite",
1288 common_ancestor="def456",
1289 dimensions=(structural_dim,),
1290 overall_score=0.3,
1291 all_messages=("add new verse section", "polish intro melody"),
1292 )
1293 resp = build_proposal_diff_response(
1294 proposal_id="proposal-2",
1295 from_branch="feat/rewrite",
1296 to_branch="main",
1297 result=result,
1298 )
1299 assert "Verse" in resp.affected_sections
1300 assert "Intro" in resp.affected_sections
1301 assert "Chorus" not in resp.affected_sections
1302
1303
1304 # ---------------------------------------------------------------------------
1305 # Regression — server-side proposal merge snapshot correctness
1306 # ---------------------------------------------------------------------------
1307
1308
1309 async def _push_branch_with_snapshot(
1310 db: AsyncSession,
1311 repo_id: str,
1312 branch_name: str,
1313 manifest: StrDict,
1314 message: str = "commit",
1315 parent_ids: list[str] | None = None,
1316 ) -> tuple[str, str]:
1317 """Insert a branch with one commit and a real snapshot; return (commit_id, snapshot_id)."""
1318 snapshot_id = compute_snapshot_id(manifest)
1319 now = datetime.now(tz=timezone.utc)
1320 commit_id = compute_commit_id(parent_ids or [], snapshot_id, message, now.isoformat())
1321
1322 snap = MusehubSnapshot(
1323 snapshot_id=snapshot_id,
1324 repo_id=repo_id,
1325 manifest_blob=msgpack.packb(manifest, use_bin_type=True),
1326 entry_count=len(manifest),
1327 )
1328 commit = MusehubCommit(
1329 commit_id=commit_id,
1330 repo_id=repo_id,
1331 branch=branch_name,
1332 parent_ids=parent_ids or [],
1333 message=message,
1334 author="testuser",
1335 timestamp=now,
1336 snapshot_id=snapshot_id,
1337 )
1338 branch = MusehubBranch(
1339 repo_id=repo_id,
1340 name=branch_name,
1341 head_commit_id=commit_id,
1342 )
1343 db.add(snap)
1344 db.add(commit)
1345 db.add(branch)
1346 await db.commit()
1347 return commit_id, snapshot_id
1348
1349
1350 async def test_merge_proposal_snapshot_includes_to_branch_only_files(
1351 client: AsyncClient,
1352 auth_headers: StrDict,
1353 db_session: AsyncSession,
1354 ) -> None:
1355 """Regression: merge commit snapshot must contain to_branch-only files.
1356
1357 Bug: merge_proposal used from_head_snapshot_id verbatim as the merge commit's
1358 snapshot. When to_branch (main) had files that from_branch never touched
1359 (e.g. executor.py added after the proposal branch was cut), those files were
1360 absent from the merge commit's snapshot. A subsequent checkout would then
1361 delete executor.py from the working tree, reproducing the MuseHub incident.
1362
1363 Expected: merge commit snapshot = from_branch manifest ∪ to_branch-only files.
1364 """
1365 from sqlalchemy import select
1366 from musehub.db.musehub_models import MusehubCommit as DbCommit
1367 from musehub.db.musehub_models import MusehubSnapshot as DbSnapshot
1368
1369 repo_id = await _create_repo(client, auth_headers, "snapshot-correctness-repo")
1370
1371 h = lambda s: __import__("hashlib").sha256(s.encode()).hexdigest() # noqa: E731
1372
1373 # to_branch (main) has: database.py v1 + executor.py (added after branch diverged).
1374 to_commit, to_snap_id = await _push_branch_with_snapshot(
1375 db_session, repo_id, "main",
1376 manifest={"database.py": h("db-v1"), "executor.py": h("executor-fixed")},
1377 message="main: add executor.py",
1378 )
1379
1380 # from_branch (feat) has: database.py v2 + new_feature.py (executor.py absent).
1381 from_commit, from_snap_id = await _push_branch_with_snapshot(
1382 db_session, repo_id, "feat/add-feature",
1383 manifest={"database.py": h("db-v2"), "new_feature.py": h("new-feature")},
1384 message="feat: database v2 + new_feature.py",
1385 )
1386
1387 p = await _create_proposal_helper(
1388 client, auth_headers, repo_id,
1389 title="Add new feature",
1390 from_branch="feat/add-feature",
1391 to_branch="main",
1392 )
1393
1394 merge_resp = await client.post(
1395 f"/api/repos/{repo_id}/proposals/{p['proposalId']}/merge",
1396 json={"mergeStrategy": "merge_commit"},
1397 headers=auth_headers,
1398 )
1399 assert merge_resp.status_code == 200, merge_resp.text
1400 merge_commit_id: str = str(merge_resp.json()["mergeCommitId"])
1401
1402 # Load the merge commit's snapshot from the DB.
1403 result = await db_session.execute(
1404 select(DbCommit).where(DbCommit.commit_id == merge_commit_id)
1405 )
1406 merge_commit = result.scalar_one_or_none()
1407 assert merge_commit is not None, "merge commit must be stored in DB"
1408 assert merge_commit.snapshot_id is not None, "merge commit must have a snapshot"
1409
1410 snap_result = await db_session.execute(
1411 select(DbSnapshot).where(DbSnapshot.snapshot_id == merge_commit.snapshot_id)
1412 )
1413 snap = snap_result.scalar_one_or_none()
1414 assert snap is not None, f"snapshot {merge_commit.snapshot_id[:8]} must exist in DB"
1415
1416 from musehub.services.musehub_snapshot import get_snapshot_manifest
1417 manifest = await get_snapshot_manifest(db_session, merge_commit.snapshot_id)
1418
1419 # from_branch-only: new_feature.py must be present.
1420 assert "new_feature.py" in manifest, (
1421 "REGRESSION: new_feature.py (from_branch-only addition) absent from merge commit snapshot."
1422 )
1423
1424 # to_branch-only: executor.py must be present.
1425 assert "executor.py" in manifest, (
1426 "REGRESSION: executor.py (to_branch-only file) absent from merge commit snapshot.\n"
1427 "The server merge_proposal used from_branch snapshot verbatim and discarded\n"
1428 "all to_branch-only changes — identical data loss to the strategy=ours bug."
1429 )
1430
1431
1432 async def test_merge_proposal_snapshot_is_not_from_branch_verbatim(
1433 client: AsyncClient,
1434 auth_headers: StrDict,
1435 db_session: AsyncSession,
1436 ) -> None:
1437 """Regression: merge commit snapshot must NOT equal from_branch snapshot verbatim.
1438
1439 If they're equal, it means to_branch-only changes were silently discarded.
1440 """
1441 from sqlalchemy import select
1442 from musehub.db.musehub_models import MusehubCommit as DbCommit
1443
1444 repo_id = await _create_repo(client, auth_headers, "snapshot-not-verbatim-repo")
1445
1446 h = lambda s: __import__("hashlib").sha256(s.encode()).hexdigest() # noqa: E731
1447
1448 await _push_branch_with_snapshot(
1449 db_session, repo_id, "main",
1450 manifest={"shared.py": h("shared"), "to-only.py": h("to-only-content")},
1451 )
1452 _, from_snap_id = await _push_branch_with_snapshot(
1453 db_session, repo_id, "feat",
1454 manifest={"shared.py": h("shared"), "from-only.py": h("from-only-content")},
1455 )
1456
1457 p = await _create_proposal_helper(
1458 client, auth_headers, repo_id,
1459 title="Merge feat",
1460 from_branch="feat",
1461 to_branch="main",
1462 )
1463 resp = await client.post(
1464 f"/api/repos/{repo_id}/proposals/{p['proposalId']}/merge",
1465 json={"mergeStrategy": "merge_commit"},
1466 headers=auth_headers,
1467 )
1468 assert resp.status_code == 200
1469 merge_commit_id = str(resp.json()["mergeCommitId"])
1470
1471 result = await db_session.execute(
1472 select(DbCommit).where(DbCommit.commit_id == merge_commit_id)
1473 )
1474 merge_commit = result.scalar_one_or_none()
1475 assert merge_commit is not None
1476
1477 assert merge_commit.snapshot_id != from_snap_id, (
1478 "REGRESSION: merge commit snapshot equals from_branch snapshot verbatim.\n"
1479 "to_branch-only file 'to-only.py' was silently discarded."
1480 )
1481
1482
1483 async def test_merge_proposal_snapshot_id_uses_correct_formula(
1484 client: AsyncClient,
1485 auth_headers: StrDict,
1486 db_session: AsyncSession,
1487 ) -> None:
1488 """Contract: the merge commit snapshot ID must equal compute_snapshot_id(merged_manifest).
1489
1490 This test locks down the hash formula used by the server-side merge_proposal path.
1491 If the server switches back to json.dumps or any other scheme, this test
1492 catches it immediately — before corrupt IDs reach production history.
1493 """
1494 from sqlalchemy import select
1495 from musehub.db.musehub_models import MusehubCommit as DbCommit
1496 from musehub.db.musehub_models import MusehubSnapshot as DbSnapshot
1497
1498 repo_id = await _create_repo(client, auth_headers, "snapshot-formula-contract-repo")
1499
1500 to_manifest = {
1501 "agentception/app.py": "sha256:aaa111",
1502 "pyproject.toml": "sha256:bbb222",
1503 }
1504 from_manifest = {
1505 "agentception/app.py": "sha256:ccc333", # overrides to_branch version
1506 "agentception/new_module.py": "sha256:ddd444",
1507 }
1508 # Expected merged manifest: from_branch values take precedence; to_branch-only
1509 # files are preserved.
1510 expected_merged = {**to_manifest, **from_manifest}
1511 expected_snapshot_id = compute_snapshot_id(expected_merged)
1512
1513 await _push_branch_with_snapshot(db_session, repo_id, "main", manifest=to_manifest)
1514 await _push_branch_with_snapshot(db_session, repo_id, "feat/formula-check", manifest=from_manifest)
1515
1516 p = await _create_proposal_helper(
1517 client, auth_headers, repo_id,
1518 title="Formula check proposal",
1519 from_branch="feat/formula-check",
1520 to_branch="main",
1521 )
1522 merge_resp = await client.post(
1523 f"/api/repos/{repo_id}/proposals/{p['proposalId']}/merge",
1524 json={"mergeStrategy": "merge_commit"},
1525 headers=auth_headers,
1526 )
1527 assert merge_resp.status_code == 200, merge_resp.text
1528 merge_commit_id = str(merge_resp.json()["mergeCommitId"])
1529
1530 commit_result = await db_session.execute(
1531 select(DbCommit).where(DbCommit.commit_id == merge_commit_id)
1532 )
1533 merge_commit = commit_result.scalar_one_or_none()
1534 assert merge_commit is not None
1535
1536 snap_result = await db_session.execute(
1537 select(DbSnapshot).where(DbSnapshot.snapshot_id == merge_commit.snapshot_id)
1538 )
1539 snap = snap_result.scalar_one_or_none()
1540 assert snap is not None
1541
1542 assert snap.snapshot_id == expected_snapshot_id, (
1543 f"Merge commit snapshot ID does not match compute_snapshot_id(merged_manifest).\n"
1544 f" server produced: {snap.snapshot_id}\n"
1545 f" formula expected: {expected_snapshot_id}\n"
1546 "The server is using a different hash formula than the muse client library — "
1547 "every proposal merge will produce corrupt snapshots that fail content-hash verification."
1548 )
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago