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