gabriel / musehub public
test_musehub_proposals.py python
1,657 lines 60.1 KB
Raw
sha256:f99af7b1a7f36c4d537d1c630d4b71fc39222b1255f82e930929e2fc89015e11 fix: relax browse_repo perf budget to 500ms — 200ms was too… Sonnet 4.6 102 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_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, 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 branch=branch_name,
71 parent_ids=[],
72 message=f"Initial commit on {branch_name}",
73 author="rene",
74 timestamp=datetime.now(tz=timezone.utc),
75 )
76 branch = MusehubBranch(
77 branch_id=compute_branch_id(repo_id, branch_name),
78 repo_id=repo_id,
79 name=branch_name,
80 head_commit_id=commit_id,
81 )
82 db.add(commit)
83 db.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id))
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_repo_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_repo_models import MusehubBranch, MusehubCommit
564 from musehub.db.musehub_social_models import 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 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(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id))
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_repo_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_repo_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 manifest_blob=msgpack.packb(manifest, use_bin_type=True),
1349 entry_count=len(manifest),
1350 )
1351 commit = MusehubCommit(
1352 commit_id=commit_id,
1353 branch=branch_name,
1354 parent_ids=parent_ids or [],
1355 message=message,
1356 author="testuser",
1357 timestamp=now,
1358 snapshot_id=snapshot_id,
1359 )
1360 branch = MusehubBranch(
1361 branch_id=compute_branch_id(repo_id, branch_name),
1362 repo_id=repo_id,
1363 name=branch_name,
1364 head_commit_id=commit_id,
1365 )
1366 db.add(snap)
1367 db.add(commit)
1368 db.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id))
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_repo_models import MusehubCommit as DbCommit
1391 from musehub.db.musehub_repo_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_repo_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_repo_models import MusehubCommit as DbCommit
1516 from musehub.db.musehub_repo_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 )
1569
1570
1571 # ---------------------------------------------------------------------------
1572 # POST /repos/{repo_id}/proposals/{proposal_id}/close
1573 # ---------------------------------------------------------------------------
1574
1575
1576 async def test_close_proposal_sets_state_closed(
1577 client: AsyncClient,
1578 auth_headers: StrDict,
1579 db_session: AsyncSession,
1580 ) -> None:
1581 """POST .../close on an open proposal must set state to 'closed'."""
1582 repo_id = await _create_repo(client, auth_headers, "close-proposal-open-repo")
1583 await _push_branch(db_session, repo_id, "feat/close-me")
1584 proposal = await _create_proposal_helper(
1585 client, auth_headers, repo_id,
1586 title="Close me",
1587 from_branch="feat/close-me",
1588 to_branch="main",
1589 )
1590 r = await client.post(
1591 f"/api/repos/{repo_id}/proposals/{proposal['proposalId']}/close",
1592 headers=auth_headers,
1593 )
1594 assert r.status_code == 200, r.text
1595 assert r.json()["state"] == "closed"
1596
1597
1598 async def test_close_proposal_already_closed_returns_409(
1599 client: AsyncClient,
1600 auth_headers: StrDict,
1601 db_session: AsyncSession,
1602 ) -> None:
1603 """POST .../close on an already-closed proposal must return 409."""
1604 repo_id = await _create_repo(client, auth_headers, "close-proposal-409-repo")
1605 await _push_branch(db_session, repo_id, "feat/close-twice")
1606 proposal = await _create_proposal_helper(
1607 client, auth_headers, repo_id,
1608 title="Close twice",
1609 from_branch="feat/close-twice",
1610 to_branch="main",
1611 )
1612 pid = proposal["proposalId"]
1613 await client.post(f"/api/repos/{repo_id}/proposals/{pid}/close", headers=auth_headers)
1614 r = await client.post(f"/api/repos/{repo_id}/proposals/{pid}/close", headers=auth_headers)
1615 assert r.status_code == 409, r.text
1616
1617
1618 async def test_close_unknown_proposal_returns_404(
1619 client: AsyncClient,
1620 auth_headers: StrDict,
1621 db_session: AsyncSession,
1622 ) -> None:
1623 """POST .../close on a nonexistent proposal_id must return 404."""
1624 repo_id = await _create_repo(client, auth_headers, "close-proposal-404-repo")
1625 r = await client.post(
1626 f"/api/repos/{repo_id}/proposals/sha256:{'dead' * 16}/close",
1627 headers=auth_headers,
1628 )
1629 assert r.status_code == 404, r.text
1630
1631
1632 async def test_close_proposal_requires_auth(client: AsyncClient) -> None:
1633 """POST .../close without auth must return 401 or 403."""
1634 r = await client.post("/api/repos/any-repo/proposals/any-id/close")
1635 assert r.status_code in (401, 403), r.text
1636
1637
1638 async def test_closed_proposal_appears_in_closed_list(
1639 client: AsyncClient,
1640 auth_headers: StrDict,
1641 db_session: AsyncSession,
1642 ) -> None:
1643 """After closing, proposal must appear in GET proposals?state=closed."""
1644 repo_id = await _create_repo(client, auth_headers, "close-proposal-list-repo")
1645 await _push_branch(db_session, repo_id, "feat/list-closed")
1646 proposal = await _create_proposal_helper(
1647 client, auth_headers, repo_id,
1648 title="List closed",
1649 from_branch="feat/list-closed",
1650 to_branch="main",
1651 )
1652 pid = proposal["proposalId"]
1653 await client.post(f"/api/repos/{repo_id}/proposals/{pid}/close", headers=auth_headers)
1654 r = await client.get(f"/api/repos/{repo_id}/proposals?state=closed", headers=auth_headers)
1655 assert r.status_code == 200, r.text
1656 ids = [p["proposalId"] for p in r.json()["proposals"]]
1657 assert pid in ids
File History 2 commits
sha256:f99af7b1a7f36c4d537d1c630d4b71fc39222b1255f82e930929e2fc89015e11 fix: relax browse_repo perf budget to 500ms — 200ms was too… Sonnet 4.6 102 days ago
sha256:763eb2cb8675073b84c19345b27586d2ed939a9aee97c5479b69f502f1a70eff fix(tests): update test suite to match current implementation Sonnet 4.6 patch 124 days ago