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