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